Pre-analysis checkpoint before implementing fixes for: - bakeStyles field mapping - error logging improvements - HTML sanitization hardening
1422 lines
46 KiB
HTML
1422 lines
46 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>Samsung TV Proxy</title>
|
||
<style>
|
||
*,
|
||
*::before,
|
||
*::after {
|
||
box-sizing: border-box;
|
||
margin: 0;
|
||
padding: 0;
|
||
}
|
||
:root {
|
||
--bg: #0f0f1a;
|
||
--surface: #1a1a2e;
|
||
--surface2: #25253e;
|
||
--border: #2a2a4a;
|
||
--text: #e0e0f0;
|
||
--muted: #8888aa;
|
||
--accent: #7c5ce7;
|
||
--accent2: #a78bfa;
|
||
--danger: #e94560;
|
||
--green: #2ecc71;
|
||
--yellow: #ffc107;
|
||
--orange: #f59e0b;
|
||
--radius: 10px;
|
||
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||
--mono: "SF Mono", "Cascadia Code", "JetBrains Mono", monospace;
|
||
}
|
||
|
||
body {
|
||
font-family: var(--font);
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
min-height: 100vh;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.app {
|
||
max-width: 960px;
|
||
margin: 0 auto;
|
||
padding: 32px 16px;
|
||
}
|
||
|
||
header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 32px;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
}
|
||
header h1 {
|
||
font-size: 24px;
|
||
font-weight: 700;
|
||
}
|
||
header h1 span {
|
||
color: var(--accent2);
|
||
}
|
||
|
||
.header-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
/* Buttons */
|
||
.btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 9px 16px;
|
||
border: none;
|
||
border-radius: var(--radius);
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
text-decoration: none;
|
||
font-family: var(--font);
|
||
}
|
||
.btn:active {
|
||
transform: scale(0.97);
|
||
}
|
||
.btn-primary {
|
||
background: var(--accent);
|
||
color: #fff;
|
||
}
|
||
.btn-primary:hover {
|
||
background: #6a4fd4;
|
||
}
|
||
.btn-ghost {
|
||
background: transparent;
|
||
color: var(--muted);
|
||
border: 1px solid var(--border);
|
||
}
|
||
.btn-ghost:hover {
|
||
background: var(--surface2);
|
||
color: var(--text);
|
||
}
|
||
.btn-danger {
|
||
background: transparent;
|
||
color: var(--danger);
|
||
border: 1px solid var(--danger);
|
||
}
|
||
.btn-danger:hover {
|
||
background: var(--danger);
|
||
color: #fff;
|
||
}
|
||
.btn-sm {
|
||
padding: 5px 10px;
|
||
font-size: 12px;
|
||
}
|
||
.btn-xs {
|
||
padding: 3px 7px;
|
||
font-size: 11px;
|
||
}
|
||
.btn-icon {
|
||
width: 32px;
|
||
height: 32px;
|
||
padding: 0;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
/* Cards */
|
||
.card {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 20px;
|
||
margin-bottom: 12px;
|
||
transition: border-color 0.15s;
|
||
}
|
||
.card:hover {
|
||
border-color: var(--accent);
|
||
}
|
||
.card-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
}
|
||
.card-title {
|
||
font-weight: 600;
|
||
font-size: 16px;
|
||
word-break: break-all;
|
||
}
|
||
.card-badges {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
margin-top: 10px;
|
||
}
|
||
.badge {
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
padding: 3px 8px;
|
||
border-radius: 6px;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
.badge-mjpeg {
|
||
background: rgba(124, 92, 231, 0.15);
|
||
color: var(--accent2);
|
||
}
|
||
.badge-ssr {
|
||
background: rgba(46, 204, 113, 0.12);
|
||
color: var(--green);
|
||
}
|
||
.badge-refresh {
|
||
background: rgba(255, 193, 7, 0.12);
|
||
color: var(--yellow);
|
||
}
|
||
.badge-bake {
|
||
background: rgba(46, 204, 113, 0.1);
|
||
color: var(--green);
|
||
}
|
||
.badge-wait {
|
||
background: rgba(255, 152, 0, 0.15);
|
||
color: #ff9800;
|
||
}
|
||
.card-url-preview {
|
||
font-size: 13px;
|
||
color: var(--muted);
|
||
margin-top: 8px;
|
||
}
|
||
.card-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-top: 14px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.url-copy-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-top: 10px;
|
||
background: var(--surface2);
|
||
border-radius: 8px;
|
||
padding: 8px 12px;
|
||
}
|
||
.url-copy-row code {
|
||
font-size: 12px;
|
||
color: var(--accent2);
|
||
word-break: break-all;
|
||
flex: 1;
|
||
user-select: all;
|
||
font-family: var(--mono);
|
||
}
|
||
|
||
/* Modal */
|
||
.modal-overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, 0.7);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 100;
|
||
padding: 16px;
|
||
}
|
||
.modal {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 14px;
|
||
padding: 28px;
|
||
max-width: 640px;
|
||
width: 100%;
|
||
max-height: 90vh;
|
||
overflow-y: auto;
|
||
position: relative;
|
||
}
|
||
.modal-close {
|
||
position: absolute;
|
||
top: 16px;
|
||
right: 16px;
|
||
width: 32px;
|
||
height: 32px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: transparent;
|
||
border: none;
|
||
color: var(--muted);
|
||
font-size: 20px;
|
||
cursor: pointer;
|
||
border-radius: 8px;
|
||
transition: all 0.15s;
|
||
}
|
||
.modal-close:hover {
|
||
background: var(--surface2);
|
||
color: var(--text);
|
||
}
|
||
.modal h2 {
|
||
font-size: 20px;
|
||
margin-bottom: 24px;
|
||
padding-right: 40px;
|
||
}
|
||
|
||
/* Form */
|
||
.field {
|
||
margin-bottom: 20px;
|
||
}
|
||
.field label {
|
||
display: block;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--muted);
|
||
margin-bottom: 6px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
.field input,
|
||
.field select {
|
||
width: 100%;
|
||
padding: 10px 12px;
|
||
border-radius: 8px;
|
||
border: 1px solid var(--border);
|
||
background: var(--surface2);
|
||
color: var(--text);
|
||
font-size: 14px;
|
||
font-family: var(--font);
|
||
outline: none;
|
||
transition: border-color 0.15s;
|
||
}
|
||
.field input:focus,
|
||
.field select:focus {
|
||
border-color: var(--accent);
|
||
}
|
||
.field select {
|
||
cursor: pointer;
|
||
}
|
||
.url-row {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
margin-bottom: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.url-row input[type="url"] {
|
||
flex: 3;
|
||
min-width: 180px;
|
||
}
|
||
.url-row input[type="number"] {
|
||
flex: 1;
|
||
min-width: 80px;
|
||
}
|
||
.checkbox-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin-bottom: 20px;
|
||
}
|
||
.checkbox-row input[type="checkbox"] {
|
||
width: 18px;
|
||
height: 18px;
|
||
cursor: pointer;
|
||
accent-color: var(--accent);
|
||
}
|
||
.checkbox-row label {
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
margin: 0;
|
||
text-transform: none;
|
||
letter-spacing: 0;
|
||
color: var(--text);
|
||
}
|
||
.dark-toggle {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 3px;
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
white-space: nowrap;
|
||
user-select: none;
|
||
}
|
||
.dark-toggle input[type="checkbox"] {
|
||
width: 14px;
|
||
height: 14px;
|
||
cursor: pointer;
|
||
accent-color: var(--accent);
|
||
}
|
||
.bake-toggle {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 3px;
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
white-space: nowrap;
|
||
user-select: none;
|
||
}
|
||
.bake-toggle input[type="checkbox"] {
|
||
width: 14px;
|
||
height: 14px;
|
||
cursor: pointer;
|
||
accent-color: var(--green);
|
||
}
|
||
.hint {
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
margin-top: 4px;
|
||
}
|
||
.modal-footer {
|
||
display: flex;
|
||
gap: 10px;
|
||
margin-top: 28px;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
/* Empty state */
|
||
.empty {
|
||
text-align: center;
|
||
padding: 60px 20px;
|
||
color: var(--muted);
|
||
}
|
||
.empty-icon {
|
||
font-size: 48px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
/* Toast */
|
||
.toast-container {
|
||
position: fixed;
|
||
bottom: 24px;
|
||
right: 24px;
|
||
z-index: 200;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
.toast {
|
||
padding: 12px 18px;
|
||
border-radius: 10px;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
animation: slideIn 0.25s ease;
|
||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||
}
|
||
.toast-success {
|
||
background: var(--green);
|
||
color: #000;
|
||
}
|
||
.toast-error {
|
||
background: var(--danger);
|
||
color: #fff;
|
||
}
|
||
@keyframes slideIn {
|
||
from {
|
||
transform: translateX(100%);
|
||
opacity: 0;
|
||
}
|
||
to {
|
||
transform: none;
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
/* DEBUG PANEL */
|
||
#debug-panel {
|
||
display: none;
|
||
margin-top: 40px;
|
||
border-top: 2px solid var(--accent);
|
||
padding-top: 24px;
|
||
}
|
||
#debug-panel.visible {
|
||
display: block;
|
||
}
|
||
|
||
.debug-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 20px;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
.debug-header h2 {
|
||
font-size: 18px;
|
||
color: var(--accent2);
|
||
}
|
||
.debug-header .live-dot {
|
||
width: 10px;
|
||
height: 10px;
|
||
background: var(--green);
|
||
border-radius: 50%;
|
||
animation: pulse 1s infinite;
|
||
}
|
||
@keyframes pulse {
|
||
0%,
|
||
100% {
|
||
opacity: 1;
|
||
}
|
||
50% {
|
||
opacity: 0.3;
|
||
}
|
||
}
|
||
|
||
.debug-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||
gap: 12px;
|
||
margin-bottom: 24px;
|
||
}
|
||
.debug-stat {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 14px;
|
||
text-align: center;
|
||
}
|
||
.debug-stat .value {
|
||
font-size: 28px;
|
||
font-weight: 700;
|
||
color: var(--accent2);
|
||
font-family: var(--mono);
|
||
}
|
||
.debug-stat .label {
|
||
font-size: 11px;
|
||
color: var(--muted);
|
||
margin-top: 4px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.debug-section {
|
||
margin-bottom: 24px;
|
||
}
|
||
.debug-section h3 {
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
color: var(--muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.debug-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 13px;
|
||
}
|
||
.debug-table th {
|
||
text-align: left;
|
||
padding: 8px 10px;
|
||
color: var(--muted);
|
||
font-size: 11px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.debug-table td {
|
||
padding: 8px 10px;
|
||
border-bottom: 1px solid rgba(42, 42, 74, 0.5);
|
||
font-family: var(--mono);
|
||
font-size: 12px;
|
||
}
|
||
.debug-table tr:hover td {
|
||
background: rgba(124, 92, 231, 0.05);
|
||
}
|
||
.debug-table .url-cell {
|
||
max-width: 200px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.debug-table .tick {
|
||
color: var(--yellow);
|
||
font-weight: 700;
|
||
}
|
||
.debug-table .fresh {
|
||
color: var(--green);
|
||
}
|
||
.debug-table .expired {
|
||
color: var(--danger);
|
||
}
|
||
|
||
.device-actions {
|
||
display: flex;
|
||
gap: 4px;
|
||
}
|
||
|
||
.confirm-text {
|
||
font-size: 15px;
|
||
margin-bottom: 24px;
|
||
color: var(--muted);
|
||
line-height: 1.6;
|
||
}
|
||
.confirm-text strong {
|
||
color: var(--text);
|
||
}
|
||
|
||
@media (max-width: 640px) {
|
||
.app {
|
||
padding: 16px 10px;
|
||
}
|
||
.card {
|
||
padding: 14px;
|
||
}
|
||
.debug-grid {
|
||
grid-template-columns: repeat(2, 1fr);
|
||
}
|
||
.debug-table {
|
||
font-size: 11px;
|
||
}
|
||
.debug-table td,
|
||
.debug-table th {
|
||
padding: 6px 6px;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="app">
|
||
<header>
|
||
<h1>Samsung <span>TV Proxy</span></h1>
|
||
<div class="header-actions">
|
||
<button class="btn btn-ghost btn-sm" id="debug-toggle" onclick="toggleDebug()">
|
||
🔍 Debug
|
||
</button>
|
||
<button class="btn btn-ghost btn-sm" onclick="openImportModal()">📤 Import</button>
|
||
<button class="btn btn-primary" onclick="openCreateModal()">+ New View</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div id="views-list"></div>
|
||
|
||
<!-- DEBUG PANEL -->
|
||
<div id="debug-panel">
|
||
<div class="debug-header">
|
||
<h2>🔍 Live Debug</h2>
|
||
<div style="display: flex; align-items: center; gap: 8px">
|
||
<div class="live-dot"></div>
|
||
<span style="font-size:12px;color:var(--muted);">every</span>
|
||
<select id="debug-interval" onchange="setDebugInterval(parseInt(this.value))"
|
||
style="font-size:12px;background:var(--surface2);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:2px 6px;margin:0 4px;">
|
||
<option value="100">100ms</option>
|
||
<option value="250">250ms</option>
|
||
<option value="500">500ms</option>
|
||
<option value="1000" selected>1s</option>
|
||
<option value="2000">2s</option>
|
||
<option value="5000">5s</option>
|
||
</select>
|
||
<button class="btn btn-ghost btn-xs" onclick="toggleDebug()">✕</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="debug-grid" id="debug-stats">
|
||
<div class="debug-stat">
|
||
<div class="value" id="stat-uptime">—</div>
|
||
<div class="label">Uptime (s)</div>
|
||
</div>
|
||
<div class="debug-stat">
|
||
<div class="value" id="stat-views">—</div>
|
||
<div class="label">Views</div>
|
||
</div>
|
||
<div class="debug-stat">
|
||
<div class="value" id="stat-renders">—</div>
|
||
<div class="label">Active Renders</div>
|
||
</div>
|
||
<div class="debug-stat">
|
||
<div class="value" id="stat-queue">—</div>
|
||
<div class="label">Queue Depth</div>
|
||
</div>
|
||
<div class="debug-stat">
|
||
<div class="value" id="stat-cache">—</div>
|
||
<div class="label">Cache Entries</div>
|
||
</div>
|
||
<div class="debug-stat">
|
||
<div class="value" id="stat-devices">—</div>
|
||
<div class="label">Devices</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="debug-section">
|
||
<h3>📺 Views & Rotation</h3>
|
||
<div style="overflow-x: auto">
|
||
<table class="debug-table">
|
||
<thead>
|
||
<tr>
|
||
<th>View</th>
|
||
<th>Method</th>
|
||
<th>Viewport</th>
|
||
<th>Current URL</th>
|
||
<th>Idx</th>
|
||
<th class="tick">Remaining</th>
|
||
<th>Cycle</th>
|
||
<th>Cache TTL</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="debug-views-tbody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="debug-section">
|
||
<h3>🗄 Cache Entries</h3>
|
||
<div style="overflow-x: auto">
|
||
<table class="debug-table">
|
||
<thead>
|
||
<tr>
|
||
<th>View</th>
|
||
<th>Idx</th>
|
||
<th>URL</th>
|
||
<th class="tick">TTL left</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="debug-cache-tbody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="debug-section">
|
||
<h3>📡 Connected Devices</h3>
|
||
<div style="overflow-x: auto">
|
||
<table class="debug-table">
|
||
<thead>
|
||
<tr>
|
||
<th>IP</th>
|
||
<th>View</th>
|
||
<th>Requests</th>
|
||
<th>Last Seen</th>
|
||
<th>User Agent</th>
|
||
<th>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="debug-devices-tbody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="debug-section">
|
||
<h3>🚫 Banned IPs</h3>
|
||
<div style="overflow-x: auto">
|
||
<table class="debug-table">
|
||
<thead>
|
||
<tr>
|
||
<th>IP</th>
|
||
<th>Banned At</th>
|
||
<th>Reason</th>
|
||
<th>Banned By</th>
|
||
<th>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="debug-banned-tbody"><tr><td colspan="5" style="color:var(--muted);text-align:center;">No banned IPs</td></tr></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="modal-container"></div>
|
||
<div class="toast-container" id="toast-container"></div>
|
||
|
||
<script>
|
||
// ═══════════════════════════════════════════════
|
||
// STATE
|
||
// ═══════════════════════════════════════════════
|
||
|
||
let views = [];
|
||
let debugSocket = null;
|
||
let debugReconnectTimer = null;
|
||
let debugVisible = false;
|
||
let debugIntervalMs = 1000;
|
||
const { origin } = location;
|
||
|
||
// ═══════════════════════════════════════════════
|
||
// TOAST
|
||
// ═══════════════════════════════════════════════
|
||
|
||
function toast(msg, type = "success") {
|
||
const el = document.getElementById("toast-container");
|
||
const t = document.createElement("div");
|
||
t.className = "toast toast-" + type;
|
||
t.textContent = msg;
|
||
el.appendChild(t);
|
||
setTimeout(() => {
|
||
t.style.opacity = "0";
|
||
t.style.transition = "opacity 0.3s";
|
||
setTimeout(() => t.remove(), 300);
|
||
}, 2500);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════
|
||
// API
|
||
// ═══════════════════════════════════════════════
|
||
|
||
async function api(method, path, body) {
|
||
const opts = { method, headers: { "Content-Type": "application/json" } };
|
||
if (body) opts.body = JSON.stringify(body);
|
||
const res = await fetch(origin + path, opts);
|
||
const data = await res.json();
|
||
if (!res.ok) throw new Error(data.error || "Request failed");
|
||
return data;
|
||
}
|
||
|
||
async function loadViews() {
|
||
try {
|
||
views = await api("GET", "/api/views");
|
||
renderViews();
|
||
} catch (err) {
|
||
toast(err.message, "error");
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════
|
||
// RENDER VIEWS LIST
|
||
// ═══════════════════════════════════════════════
|
||
|
||
function renderViews() {
|
||
const container = document.getElementById("views-list");
|
||
if (!views.length) {
|
||
container.innerHTML = `
|
||
<div class="empty">
|
||
<div class="empty-icon">📺</div>
|
||
<p>No views yet. Create one to start serving<br>dynamic content to your Samsung TV.</p>
|
||
<button class="btn btn-primary" onclick="openCreateModal()">+ Create First View</button>
|
||
</div>`;
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = views
|
||
.map((v) => {
|
||
const viewUrl = origin + "/v/" + v.id;
|
||
const urlPreview = v.urls.map((u, i) =>
|
||
`${u.url} (${u.durationSec}s${u.waitBeforeCaptureMs ? ` +${u.waitBeforeCaptureMs}ms` : ""})${u.forceDarkMode ? " 🌙" : ""}${u.bakeStyles ? " 🍞" : ""}`
|
||
).join(" → ");
|
||
const anyBake = v.urls.some((u) => u.bakeStyles);
|
||
const anyWait = v.urls.some((u) => u.waitBeforeCaptureMs > 0);
|
||
const badges = [
|
||
`<span class="badge badge-${v.method}">${v.method.toUpperCase()}</span>`,
|
||
v.metaRefreshEnabled ? '<span class="badge badge-refresh">META-REFRESH</span>' : "",
|
||
anyBake ? '<span class="badge badge-bake">BAKE</span>' : "",
|
||
anyWait ? '<span class="badge badge-wait">WAIT</span>' : "",
|
||
]
|
||
.filter(Boolean)
|
||
.join(" ");
|
||
|
||
return `
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<span class="card-title">${esc(v.name)}</span>
|
||
<div style="display:flex;gap:6px;">
|
||
<button class="btn btn-icon btn-ghost btn-sm" onclick="exportView('${v.id}')" title="Export">📥</button>
|
||
<button class="btn btn-icon btn-ghost btn-sm" onclick="openEditModal('${v.id}')" title="Edit">✏️</button>
|
||
<button class="btn btn-icon btn-danger btn-sm" onclick="confirmDelete('${v.id}','${esc(v.name)}')" title="Delete">🗑</button>
|
||
</div>
|
||
</div>
|
||
<div class="card-badges">${badges}</div>
|
||
<div class="card-url-preview">${esc(urlPreview)}</div>
|
||
<div class="url-copy-row">
|
||
<code>${viewUrl}</code>
|
||
<button class="btn btn-sm btn-primary" onclick="copyUrl('${viewUrl}'); toast('Copied!')">Copy</button>
|
||
</div>
|
||
</div>`;
|
||
})
|
||
.join("");
|
||
}
|
||
|
||
function esc(s) {
|
||
const d = document.createElement("div");
|
||
d.textContent = s;
|
||
return d.innerHTML;
|
||
}
|
||
function copyUrl(url) {
|
||
navigator.clipboard.writeText(url).catch(() => {});
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════
|
||
// MODAL SYSTEM
|
||
// ═══════════════════════════════════════════════
|
||
|
||
function openModal(html) {
|
||
document.getElementById("modal-container").innerHTML = `
|
||
<div class="modal-overlay">
|
||
<div class="modal">
|
||
<button class="modal-close" onclick="closeModal()">✕</button>
|
||
${html}
|
||
</div>
|
||
</div>`;
|
||
document.body.style.overflow = "hidden";
|
||
}
|
||
function closeModal() {
|
||
document.getElementById("modal-container").innerHTML = "";
|
||
document.body.style.overflow = "";
|
||
}
|
||
|
||
function openCreateModal() {
|
||
openModal(buildEditorHTML(null));
|
||
}
|
||
function openEditModal(id) {
|
||
const v = views.find((v) => v.id === id);
|
||
if (v) openModal(buildEditorHTML(v));
|
||
}
|
||
|
||
function confirmDelete(id, name) {
|
||
openModal(`
|
||
<h2>Delete View</h2>
|
||
<div class="confirm-text">
|
||
Remove <strong>${name}</strong>?<br>
|
||
This cannot be undone. URL <code style="background:var(--surface2);padding:2px 6px;border-radius:4px;">/v/${id}</code> will stop.
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
|
||
<button class="btn btn-danger" onclick="doDelete('${id}')">Delete</button>
|
||
</div>`);
|
||
}
|
||
|
||
async function doDelete(id) {
|
||
try {
|
||
await api("DELETE", "/api/views/" + id);
|
||
closeModal();
|
||
toast("View deleted");
|
||
loadViews();
|
||
} catch (err) {
|
||
toast(err.message, "error");
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════
|
||
// EXPORT / IMPORT
|
||
// ═══════════════════════════════════════════════
|
||
|
||
async function exportView(id) {
|
||
try {
|
||
const response = await fetch("/api/views/" + id + "?export=true");
|
||
if (!response.ok) throw new Error("Export failed");
|
||
|
||
const blob = await response.blob();
|
||
const url = window.URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = `view-${id}.json`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
window.URL.revokeObjectURL(url);
|
||
a.remove();
|
||
toast("View exported!");
|
||
} catch (err) {
|
||
toast(err.message, "error");
|
||
}
|
||
}
|
||
|
||
function openImportModal() {
|
||
openModal(`
|
||
<h2>Import View</h2>
|
||
<form id="import-form" onsubmit="handleImport(event)">
|
||
<div class="field">
|
||
<label>Paste View JSON</label>
|
||
<textarea id="import-json" rows="12" placeholder='{"name":"My View","urls":[{"url":"https://example.com","durationSec":30}],"method":"ssr"}' required style="font-family:monospace;font-size:13px;"></textarea>
|
||
<div class="hint">Paste the JSON content from an exported view file.</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-ghost" onclick="closeModal()">Cancel</button>
|
||
<button type="submit" class="btn btn-primary">Import View</button>
|
||
</div>
|
||
</form>`);
|
||
}
|
||
|
||
async function handleImport(e) {
|
||
e.preventDefault();
|
||
const jsonText = document.getElementById("import-json").value.trim();
|
||
if (!jsonText) {
|
||
toast("Please paste JSON content", "error");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Validate JSON structure
|
||
const data = JSON.parse(jsonText);
|
||
if (!data.name || !data.urls || !Array.isArray(data.urls)) {
|
||
toast("Invalid view JSON: missing required fields", "error");
|
||
return;
|
||
}
|
||
|
||
await api("POST", "/api/views/import", data);
|
||
closeModal();
|
||
toast("View imported successfully!");
|
||
loadViews();
|
||
} catch (err) {
|
||
if (err.message.includes("JSON")) {
|
||
toast("Invalid JSON format", "error");
|
||
} else {
|
||
toast(err.message, "error");
|
||
}
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════
|
||
// EDITOR BUILDER
|
||
// ═══════════════════════════════════════════════
|
||
|
||
function buildEditorHTML(existing) {
|
||
const isEdit = !!existing;
|
||
const title = isEdit ? "Edit View" : "Create New View";
|
||
const idAttr = isEdit ? ` data-id="${existing.id}"` : "";
|
||
const urls = existing ? existing.urls : [{ url: "", durationSec: 30, waitBeforeCaptureMs: 0 }];
|
||
let urlRowsHTML = urls
|
||
.map((u, i) => buildUrlRowHTML(u.url, u.durationSec, u.waitBeforeCaptureMs, i, urls.length, u.forceDarkMode, u.bakeStyles))
|
||
.join("");
|
||
const method = existing ? existing.method : "ssr";
|
||
const metaChecked = existing ? existing.metaRefreshEnabled : false;
|
||
const metaSectionStyle = method === "ssr" ? "" : "display:none;";
|
||
const cacheTtl = existing ? existing.cacheTtlSec : 60;
|
||
const vpW = existing ? existing.viewportWidth : 1920;
|
||
const vpH = existing ? existing.viewportHeight : 1080;
|
||
|
||
return `
|
||
<h2>${title}</h2>
|
||
<form id="view-form" onsubmit="handleSave(event)"${idAttr}>
|
||
|
||
<div class="field">
|
||
<label>View Name</label>
|
||
<input type="text" id="view-name" placeholder="e.g. Office Dashboard" value="${existing ? esc(existing.name) : ""}" required autofocus>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label>URLs & Durations</label>
|
||
<div id="url-rows">${urlRowsHTML}</div>
|
||
<button type="button" class="btn btn-ghost btn-sm" onclick="addUrlRow()" style="margin-top:8px;">+ Add URL</button>
|
||
<div class="hint">Each URL plays for its duration. Single URL = static display.</div>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label>Serving Method</label>
|
||
<select id="view-method" onchange="onMethodChange()">
|
||
<option value="ssr" ${method === "ssr" ? "selected" : ""}>SSR (Server-Side Render + inline)</option>
|
||
<option value="mjpeg" ${method === "mjpeg" ? "selected" : ""}>Screenshot (JPEG frame + meta-refresh)</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div id="meta-section" style="${metaSectionStyle}">
|
||
<div class="checkbox-row">
|
||
<input type="checkbox" id="meta-refresh" ${metaChecked ? "checked" : ""}>
|
||
<label for="meta-refresh">Inject <meta http-equiv="refresh"> tag</label>
|
||
</div>
|
||
<div class="hint" style="margin-top:-8px;margin-bottom:16px;">TV reloads when rotation advances. Required for multi-URL on non-JS browsers.</div>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label>Cache TTL (seconds)</label>
|
||
<input type="number" id="view-cache-ttl" value="${cacheTtl}" min="0" max="86400" required>
|
||
<div class="hint">How long a render stays in cache. 0 = always re-render.</div>
|
||
</div>
|
||
|
||
<div class="field" style="display:flex;gap:12px;">
|
||
<div style="flex:1;">
|
||
<label>Viewport Width</label>
|
||
<input type="number" id="viewport-width" value="${vpW}" min="320" max="7680" required>
|
||
<div class="hint">Browser render width (px)</div>
|
||
</div>
|
||
<div style="flex:1;">
|
||
<label>Viewport Height</label>
|
||
<input type="number" id="viewport-height" value="${vpH}" min="240" max="4320" required>
|
||
<div class="hint">Browser render height (px)</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-ghost" onclick="closeModal()">Cancel</button>
|
||
<button type="submit" class="btn btn-primary">${isEdit ? "Save Changes" : "Create View"}</button>
|
||
</div>
|
||
</form>`;
|
||
}
|
||
|
||
function buildUrlRowHTML(url, durationSec, waitBeforeCaptureMs, index, total, forceDarkMode, bakeStyles) {
|
||
const isSingle = total <= 1;
|
||
const disabledAttr = isSingle ? "disabled" : "";
|
||
const grayStyle = isSingle ? 'style="opacity:0.4;"' : "";
|
||
const darkChecked = forceDarkMode ? "checked" : "";
|
||
const bakeChecked = bakeStyles ? "checked" : "";
|
||
const waitVal = waitBeforeCaptureMs ?? 0;
|
||
return `
|
||
<div class="url-row" data-index="${index}">
|
||
<input type="url" placeholder="https://example.com/slideshow" value="${esc(url || "")}" class="url-input" required>
|
||
<div style="display:flex;gap:8px;flex:1;">
|
||
<input type="number" placeholder="Seconds" value="${durationSec || 30}" min="1" max="86400" class="duration-input" ${disabledAttr} ${grayStyle} required title="Display duration">
|
||
<input type="number" placeholder="Wait ms" value="${waitVal}" min="0" max="60000" class="wait-input" title="Wait before capture (ms)">
|
||
</div>
|
||
<label class="dark-toggle" title="Force dark mode">
|
||
<input type="checkbox" class="dark-mode-input" ${darkChecked}>
|
||
🌙
|
||
</label>
|
||
<label class="bake-toggle" title="Bake computed styles (Samsung TV compatibility)">
|
||
<input type="checkbox" class="bake-styles-input" ${bakeChecked}>
|
||
🍞
|
||
</label>
|
||
<button type="button" class="btn btn-icon btn-danger btn-sm" onclick="removeUrlRow(this)" ${isSingle ? "disabled" : ""}>✕</button>
|
||
</div>`;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════
|
||
// EDITOR INTERACTIONS
|
||
// ═══════════════════════════════════════════════
|
||
|
||
function addUrlRow() {
|
||
const container = document.getElementById("url-rows");
|
||
const rows = container.querySelectorAll(".url-row");
|
||
const div = document.createElement("div");
|
||
div.innerHTML = buildUrlRowHTML("", 30, 0, rows.length, rows.length + 1, false, false);
|
||
container.appendChild(div.firstElementChild);
|
||
updateUrlRowStates();
|
||
}
|
||
|
||
function removeUrlRow(btn) {
|
||
btn.closest(".url-row").remove();
|
||
updateUrlRowStates();
|
||
}
|
||
|
||
function updateUrlRowStates() {
|
||
const rows = document.querySelectorAll("#url-rows .url-row");
|
||
const total = rows.length;
|
||
rows.forEach((row, i) => {
|
||
row.setAttribute("data-index", i);
|
||
const dur = row.querySelector(".duration-input");
|
||
const del = row.querySelector(".btn-danger");
|
||
if (total <= 1) {
|
||
dur.disabled = true;
|
||
dur.style.opacity = "0.4";
|
||
del.disabled = true;
|
||
} else {
|
||
dur.disabled = false;
|
||
dur.style.opacity = "";
|
||
del.disabled = false;
|
||
}
|
||
});
|
||
}
|
||
|
||
function onMethodChange() {
|
||
const method = document.getElementById("view-method").value;
|
||
document.getElementById("meta-section").style.display = method === "ssr" ? "" : "none";
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════
|
||
// SAVE
|
||
// ═══════════════════════════════════════════════
|
||
|
||
async function handleSave(e) {
|
||
e.preventDefault();
|
||
const form = document.getElementById("view-form");
|
||
const isEdit = form.hasAttribute("data-id");
|
||
const viewId = form.getAttribute("data-id");
|
||
|
||
const name = document.getElementById("view-name").value.trim();
|
||
const method = document.getElementById("view-method").value;
|
||
const metaRefreshEnabled = document.getElementById("meta-refresh").checked;
|
||
const cacheTtlSec = parseInt(document.getElementById("view-cache-ttl").value) || 60;
|
||
const viewportWidth = parseInt(document.getElementById("viewport-width").value) || 1920;
|
||
const viewportHeight = parseInt(document.getElementById("viewport-height").value) || 1080;
|
||
|
||
const urls = [];
|
||
for (const row of document.querySelectorAll("#url-rows .url-row")) {
|
||
const urlVal = row.querySelector(".url-input").value.trim();
|
||
if (!urlVal) continue;
|
||
const forceDarkMode = row.querySelector(".dark-mode-input")?.checked ?? false;
|
||
const bakeStyles = row.querySelector(".bake-styles-input")?.checked ?? false;
|
||
const waitBeforeCaptureMs = parseInt(row.querySelector(".wait-input")?.value) || 0;
|
||
urls.push({
|
||
url: urlVal,
|
||
durationSec: parseInt(row.querySelector(".duration-input").value) || 30,
|
||
waitBeforeCaptureMs,
|
||
forceDarkMode,
|
||
bakeStyles,
|
||
});
|
||
}
|
||
|
||
if (!name) {
|
||
toast("Name is required", "error");
|
||
return;
|
||
}
|
||
if (urls.length === 0) {
|
||
toast("At least one URL is required", "error");
|
||
return;
|
||
}
|
||
|
||
const body = { name, urls, method, cacheTtlSec, viewportWidth, viewportHeight, metaRefreshEnabled };
|
||
|
||
try {
|
||
if (isEdit) {
|
||
await api("PUT", "/api/views/" + viewId, body);
|
||
toast("View updated!");
|
||
} else {
|
||
await api("POST", "/api/views", body);
|
||
toast("View created!");
|
||
}
|
||
closeModal();
|
||
loadViews();
|
||
} catch (err) {
|
||
toast(err.message, "error");
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════
|
||
// DEBUG PANEL (WebSocket)
|
||
// ═══════════════════════════════════════════════
|
||
|
||
function connectDebugSocket() {
|
||
if (debugSocket) return;
|
||
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
||
const wsUrl = `${protocol}//${location.host}/ws?interval=${debugIntervalMs}`;
|
||
debugSocket = new WebSocket(wsUrl);
|
||
|
||
debugSocket.onopen = () => {
|
||
if (debugReconnectTimer) { clearTimeout(debugReconnectTimer); debugReconnectTimer = null; }
|
||
};
|
||
|
||
debugSocket.onmessage = (event) => {
|
||
try {
|
||
lastDebugData = JSON.parse(event.data);
|
||
renderDebug(lastDebugData);
|
||
} catch { /* ignore */ }
|
||
};
|
||
|
||
debugSocket.onclose = () => {
|
||
debugSocket = null;
|
||
if (debugVisible) {
|
||
debugReconnectTimer = setTimeout(connectDebugSocket, 2000);
|
||
}
|
||
};
|
||
|
||
debugSocket.onerror = () => {
|
||
debugSocket?.close();
|
||
};
|
||
}
|
||
|
||
function disconnectDebugSocket() {
|
||
if (debugReconnectTimer) { clearTimeout(debugReconnectTimer); debugReconnectTimer = null; }
|
||
if (debugSocket) {
|
||
debugSocket.onclose = null; // prevent auto-reconnect
|
||
debugSocket.close();
|
||
debugSocket = null;
|
||
}
|
||
if (debugTickInterval) { clearInterval(debugTickInterval); debugTickInterval = null; }
|
||
}
|
||
|
||
function setDebugInterval(ms) {
|
||
debugIntervalMs = ms;
|
||
if (debugSocket && debugSocket.readyState === WebSocket.OPEN) {
|
||
debugSocket.send(JSON.stringify({ type: "debug-interval", interval: ms }));
|
||
}
|
||
}
|
||
|
||
function toggleDebug() {
|
||
debugVisible = !debugVisible;
|
||
const panel = document.getElementById("debug-panel");
|
||
const btn = document.getElementById("debug-toggle");
|
||
if (debugVisible) {
|
||
panel.classList.add("visible");
|
||
btn.textContent = "🔍 Hide Debug";
|
||
connectDebugSocket();
|
||
} else {
|
||
panel.classList.remove("visible");
|
||
btn.textContent = "🔍 Debug";
|
||
disconnectDebugSocket();
|
||
}
|
||
}
|
||
|
||
let lastDebugData = null;
|
||
let debugTickInterval = null;
|
||
|
||
function renderDebug(data) {
|
||
const s = data.server;
|
||
document.getElementById("stat-uptime").textContent = s.uptime;
|
||
document.getElementById("stat-views").textContent = data.views.length;
|
||
document.getElementById("stat-renders").textContent = s.activeRenders;
|
||
document.getElementById("stat-queue").textContent = s.queueLength;
|
||
document.getElementById("stat-cache").textContent = s.cacheEntries;
|
||
document.getElementById("stat-devices").textContent = data.devices.length;
|
||
|
||
// ── Views table ──
|
||
document.getElementById("debug-views-tbody").innerHTML = data.views
|
||
.map((v) => {
|
||
const methodBadge =
|
||
v.method === "mjpeg"
|
||
? '<span class="badge badge-mjpeg">MJPEG</span>'
|
||
: '<span class="badge badge-ssr">SSR</span>';
|
||
return `
|
||
<tr>
|
||
<td title="${esc(v.id)}">${esc(v.name)}</td>
|
||
<td>${methodBadge}</td>
|
||
<td>${v.viewportWidth}×${v.viewportHeight}</td>
|
||
<td class="url-cell" title="${esc(v.currentUrl)}">${esc(v.currentUrl)}</td>
|
||
<td>${v.currentUrlIndex}/${v.urls.length}</td>
|
||
<td class="tick" data-remaining="${v.remainingSec}" data-view-id="${v.id}">${v.remainingSec}s</td>
|
||
<td>${v.totalCycleSec}s</td>
|
||
<td>${v.cacheTtlSec}s</td>
|
||
</tr>`;
|
||
})
|
||
.join("");
|
||
|
||
// ── Cache table ──
|
||
document.getElementById("debug-cache-tbody").innerHTML = data.views
|
||
.flatMap((v) =>
|
||
v.cacheEntries.map(
|
||
(c) => `
|
||
<tr>
|
||
<td title="${esc(v.id)}">${esc(v.name)}</td>
|
||
<td>${c.urlIndex}/${v.urls.length}</td>
|
||
<td class="url-cell" title="${esc(c.url)}">${esc(c.url)}</td>
|
||
<td class="tick" data-ttl="${c.ttlRemainingSec}" data-cache-key="${c.key}">${c.ttlRemainingSec}s</td>
|
||
</tr>`,
|
||
),
|
||
)
|
||
.join("");
|
||
|
||
// ── Devices table ──
|
||
document.getElementById("debug-devices-tbody").innerHTML = data.devices
|
||
.map((d) => {
|
||
const ago = Math.round((Date.now() - d.lastSeen) / 1000);
|
||
const ipEnc = encodeURIComponent(d.ip);
|
||
return `
|
||
<tr>
|
||
<td>${esc(d.ip)}</td>
|
||
<td title="${esc(d.viewId)}">${esc(d.viewName)}</td>
|
||
<td>${d.requestCount}</td>
|
||
<td>${ago}s ago</td>
|
||
<td class="url-cell" title="${esc(d.userAgent)}">${esc(d.userAgent.slice(0, 60))}</td>
|
||
<td>
|
||
<div class="device-actions">
|
||
<button class="btn btn-xs btn-ghost" onclick="adminRefresh('${ipEnc}')" title="Force cache refresh">↺</button>
|
||
<button class="btn btn-xs btn-ghost" onclick="adminDisconnect('${ipEnc}')" title="Disconnect">⏏</button>
|
||
<button class="btn btn-xs btn-danger" onclick="adminBan('${ipEnc}', '${esc(d.ip)}')" title="Ban IP">🚫</button>
|
||
</div>
|
||
</td>
|
||
</tr>`;
|
||
})
|
||
.join("");
|
||
|
||
// ── Banned IPs table ──
|
||
fetchBannedIPs();
|
||
|
||
if (!debugTickInterval) {
|
||
debugTickInterval = setInterval(tickDebugValues, 500);
|
||
}
|
||
}
|
||
|
||
function tickDebugValues() {
|
||
document.querySelectorAll("[data-remaining]").forEach((el) => {
|
||
let val = parseFloat(el.getAttribute("data-remaining"));
|
||
val = Math.max(0, val - 0.5);
|
||
el.setAttribute("data-remaining", val);
|
||
el.textContent = val.toFixed(1) + "s";
|
||
if (val <= 3) el.style.color = "var(--danger)";
|
||
else if (val <= 10) el.style.color = "var(--orange)";
|
||
else el.style.color = "var(--yellow)";
|
||
});
|
||
|
||
document.querySelectorAll("[data-ttl]").forEach((el) => {
|
||
let val = parseFloat(el.getAttribute("data-ttl"));
|
||
val = Math.max(0, val - 0.5);
|
||
el.setAttribute("data-ttl", val);
|
||
el.textContent = val.toFixed(1) + "s";
|
||
if (val <= 0) {
|
||
el.classList.remove("fresh");
|
||
el.classList.add("expired");
|
||
}
|
||
});
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════
|
||
// ADMIN ACTIONS
|
||
// ═══════════════════════════════════════════════
|
||
|
||
async function adminDisconnect(ipEnc) {
|
||
try {
|
||
await api("POST", "/api/admin/disconnect/" + ipEnc);
|
||
toast("Device disconnected");
|
||
} catch (err) {
|
||
toast(err.message, "error");
|
||
}
|
||
}
|
||
|
||
async function adminRefresh(ipEnc) {
|
||
try {
|
||
await api("POST", "/api/admin/refresh/" + ipEnc);
|
||
toast("Cache refreshed");
|
||
} catch (err) {
|
||
toast(err.message, "error");
|
||
}
|
||
}
|
||
|
||
function adminBan(ipEnc, ipDisplay) {
|
||
openModal(`
|
||
<h2>Ban IP</h2>
|
||
<div class="confirm-text">Ban <strong>${esc(ipDisplay)}</strong>? Future connections will receive 403.</div>
|
||
<div class="field">
|
||
<label>Reason</label>
|
||
<input type="text" id="ban-reason" placeholder="Reason (optional)" value="Banned by administrator">
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
|
||
<button class="btn btn-danger" onclick="doAdminBan('${ipEnc}', '${esc(ipDisplay)}')">Ban</button>
|
||
</div>`);
|
||
}
|
||
|
||
async function doAdminBan(ipEnc, ipDisplay) {
|
||
const reason = document.getElementById("ban-reason")?.value || "Banned by administrator";
|
||
try {
|
||
await api("POST", "/api/admin/ban/" + ipEnc, { reason });
|
||
closeModal();
|
||
toast(`Banned ${decodeURIComponent(ipEnc)}`);
|
||
fetchBannedIPs();
|
||
} catch (err) {
|
||
toast(err.message, "error");
|
||
}
|
||
}
|
||
|
||
async function adminUnban(ipEnc) {
|
||
try {
|
||
await api("POST", "/api/admin/unban/" + ipEnc);
|
||
toast("IP unbanned");
|
||
fetchBannedIPs();
|
||
} catch (err) {
|
||
toast(err.message, "error");
|
||
}
|
||
}
|
||
|
||
async function fetchBannedIPs() {
|
||
try {
|
||
const banned = await api("GET", "/api/banned");
|
||
const tbody = document.getElementById("debug-banned-tbody");
|
||
if (!tbody) return;
|
||
if (!banned.length) {
|
||
tbody.innerHTML = '<tr><td colspan="5" style="color:var(--muted);text-align:center;">No banned IPs</td></tr>';
|
||
return;
|
||
}
|
||
tbody.innerHTML = banned.map((b) => {
|
||
const ipEnc = encodeURIComponent(b.ip);
|
||
const dt = new Date(b.banned_at * 1000).toLocaleString();
|
||
return `
|
||
<tr>
|
||
<td>${esc(b.ip)}</td>
|
||
<td>${esc(dt)}</td>
|
||
<td>${esc(b.reason || "—")}</td>
|
||
<td>${esc(b.banned_by || "—")}</td>
|
||
<td><button class="btn btn-xs btn-ghost" onclick="adminUnban('${ipEnc}')">Unban</button></td>
|
||
</tr>`;
|
||
}).join("");
|
||
} catch { /* silent */ }
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════
|
||
// KEYBOARD
|
||
// ═══════════════════════════════════════════════
|
||
|
||
document.addEventListener("keydown", (e) => {
|
||
if (e.key === "Escape") closeModal();
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════
|
||
// INIT
|
||
// ═══════════════════════════════════════════════
|
||
|
||
loadViews();
|
||
</script>
|
||
</body>
|
||
</html>
|