514 lines
19 KiB
HTML
514 lines
19 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;
|
|
--radius: 10px;
|
|
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
}
|
|
|
|
body {
|
|
font-family: var(--font);
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
min-height: 100vh;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
/* ── Layout ── */
|
|
.app { max-width: 900px; 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); }
|
|
|
|
/* ── 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-icon { width: 32px; height: 32px; padding: 0; display: inline-flex; align-items: center; justify-content: center; border-radius: 8px; }
|
|
|
|
/* ── Cards / List ── */
|
|
.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: #ffc107; }
|
|
|
|
.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;
|
|
}
|
|
|
|
/* ── 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: 620px; width: 100%;
|
|
max-height: 90vh; overflow-y: auto;
|
|
}
|
|
.modal h2 { font-size: 20px; margin-bottom: 24px; }
|
|
|
|
/* ── 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); }
|
|
|
|
.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; }
|
|
.empty p { margin-bottom: 20px; }
|
|
|
|
/* ── 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; } }
|
|
|
|
/* ── Confirm dialog ── */
|
|
.confirm-text { font-size: 15px; margin-bottom: 24px; color: var(--muted); line-height: 1.6; }
|
|
.confirm-text strong { color: var(--text); }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div class="app">
|
|
<header>
|
|
<h1>Samsung <span>TV Proxy</span></h1>
|
|
<button class="btn btn-primary" onclick="openCreateModal()">+ New View</button>
|
|
</header>
|
|
|
|
<div id="views-list"></div>
|
|
</div>
|
|
|
|
<!-- ── MODAL ── -->
|
|
<div id="modal-container"></div>
|
|
|
|
<!-- ── TOASTS ── -->
|
|
<div class="toast-container" id="toast-container"></div>
|
|
|
|
<script>
|
|
// ═══════════════════════════════════════════════
|
|
// STATE
|
|
// ═══════════════════════════════════════════════
|
|
|
|
let views = [];
|
|
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 urlCount = v.urls.length;
|
|
const urlPreview = v.urls.length
|
|
? (v.urls.length === 1
|
|
? v.urls[0].url
|
|
: v.urls.map((u,i) => `${u.url} (${u.durationSec}s)`).join(" → "))
|
|
: "(no URLs)";
|
|
const badges = [
|
|
`<span class="badge badge-${v.method}">${v.method.toUpperCase()}</span>`,
|
|
v.method === "ssr" && v.metaRefreshEnabled
|
|
? '<span class="badge badge-refresh">META-REFRESH</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="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" onclick="if(event.target === this) closeModal()">
|
|
<div class="modal">${html}</div>
|
|
</div>`;
|
|
document.body.style.overflow = "hidden";
|
|
}
|
|
|
|
function closeModal() {
|
|
document.getElementById("modal-container").innerHTML = "";
|
|
document.body.style.overflow = "";
|
|
}
|
|
|
|
// ── Create Modal ──
|
|
function openCreateModal() {
|
|
openModal(buildEditorHTML(null));
|
|
}
|
|
|
|
// ── Edit Modal ──
|
|
function openEditModal(id) {
|
|
const v = views.find(v => v.id === id);
|
|
if (!v) return;
|
|
openModal(buildEditorHTML(v));
|
|
}
|
|
|
|
// ── Delete Confirm ──
|
|
function confirmDelete(id, name) {
|
|
openModal(`
|
|
<h2>Delete View</h2>
|
|
<div class="confirm-text">
|
|
Remove <strong>${name}</strong>?<br>
|
|
This action cannot be undone. The URL <code style="background:var(--surface2);padding:2px 6px;border-radius:4px;">/v/${id}</code> will stop working.
|
|
</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");
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════
|
|
// EDITOR HTML BUILDER
|
|
// ═══════════════════════════════════════════════
|
|
|
|
function buildEditorHTML(existing) {
|
|
const isEdit = !!existing;
|
|
const title = isEdit ? "Edit View" : "Create New View";
|
|
const idAttr = isEdit ? ` data-id="${existing.id}"` : "";
|
|
|
|
// Build URL rows HTML
|
|
let urlRowsHTML = "";
|
|
const urls = existing ? existing.urls : [{ url: "", durationSec: 30 }];
|
|
|
|
urls.forEach((u, i) => {
|
|
urlRowsHTML += buildUrlRowHTML(u.url, u.durationSec, i, urls.length);
|
|
});
|
|
|
|
const method = existing ? existing.method : "ssr";
|
|
const metaChecked = existing ? existing.metaRefreshEnabled : false;
|
|
const metaSectionStyle = method === "ssr" ? "" : "display:none;";
|
|
|
|
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 configured 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)</option>
|
|
<option value="mjpeg" ${method === "mjpeg" ? "selected" : ""}>MJPEG Stream (screenshot frames)</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 auto-reloads page when current URL's duration expires. Essential for multi-URL rotation on non-JS browsers.</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, index, total) {
|
|
const isSingle = total <= 1;
|
|
const disabledAttr = isSingle ? "disabled" : "";
|
|
const grayClass = isSingle ? 'style="opacity:0.4;"' : "";
|
|
|
|
return `
|
|
<div class="url-row" data-index="${index}">
|
|
<input type="url" placeholder="https://example.com/slideshow" value="${esc(url || "")}" class="url-input" required>
|
|
<input type="number" placeholder="Seconds" value="${durationSec || 30}" min="1" max="86400" class="duration-input" ${disabledAttr} ${grayClass} required>
|
|
<button type="button" class="btn btn-icon btn-danger btn-sm" onclick="removeUrlRow(this)" ${total <= 1 ? "disabled" : ""}>✕</button>
|
|
</div>`;
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════
|
|
// EDITOR INTERACTIONS
|
|
// ═══════════════════════════════════════════════
|
|
|
|
function addUrlRow() {
|
|
const container = document.getElementById("url-rows");
|
|
const rows = container.querySelectorAll(".url-row");
|
|
const newIdx = rows.length;
|
|
const div = document.createElement("div");
|
|
div.innerHTML = buildUrlRowHTML("", 30, newIdx, rows.length + 1);
|
|
container.appendChild(div.firstElementChild);
|
|
updateUrlRowStates();
|
|
}
|
|
|
|
function removeUrlRow(btn) {
|
|
const row = btn.closest(".url-row");
|
|
row.remove();
|
|
updateUrlRowStates();
|
|
}
|
|
|
|
function updateUrlRowStates() {
|
|
const container = document.getElementById("url-rows");
|
|
const rows = container.querySelectorAll(".url-row");
|
|
const total = rows.length;
|
|
rows.forEach((row, i) => {
|
|
row.setAttribute("data-index", i);
|
|
const durInput = row.querySelector(".duration-input");
|
|
const delBtn = row.querySelector(".btn-danger");
|
|
if (total <= 1) {
|
|
durInput.disabled = true;
|
|
durInput.style.opacity = "0.4";
|
|
delBtn.disabled = true;
|
|
} else {
|
|
durInput.disabled = false;
|
|
durInput.style.opacity = "";
|
|
delBtn.disabled = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
function onMethodChange() {
|
|
const method = document.getElementById("view-method").value;
|
|
const metaSection = document.getElementById("meta-section");
|
|
metaSection.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;
|
|
|
|
// Collect URLs
|
|
const rows = document.querySelectorAll("#url-rows .url-row");
|
|
const urls = [];
|
|
for (const row of rows) {
|
|
const urlInput = row.querySelector(".url-input");
|
|
const durInput = row.querySelector(".duration-input");
|
|
const urlVal = urlInput.value.trim();
|
|
if (!urlVal) continue;
|
|
urls.push({
|
|
url: urlVal,
|
|
durationSec: parseInt(durInput.value) || 30,
|
|
});
|
|
}
|
|
|
|
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 };
|
|
if (method === "ssr") body.metaRefreshEnabled = metaRefreshEnabled;
|
|
|
|
try {
|
|
if (isEdit) {
|
|
await api("PUT", "/api/views/" + viewId, body);
|
|
toast("View updated!");
|
|
} else {
|
|
const created = await api("POST", "/api/views", body);
|
|
toast("View created!");
|
|
}
|
|
closeModal();
|
|
loadViews();
|
|
} catch (err) {
|
|
toast(err.message, "error");
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════
|
|
// KEYBOARD
|
|
// ═══════════════════════════════════════════════
|
|
|
|
document.addEventListener("keydown", (e) => {
|
|
if (e.key === "Escape") closeModal();
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════
|
|
// INIT
|
|
// ═══════════════════════════════════════════════
|
|
|
|
loadViews();
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|