feat(tower): Merlin Studio web UI

This commit is contained in:
Clément Saillant
2026-06-15 08:06:12 +02:00
parent 0eb3d1eb05
commit 2ffec1cc57
+533
View File
@@ -0,0 +1,533 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Merlin Studio</title>
<style>
:root {
--bg: #fdf7ff;
--card: #ffffff;
--violet: #7c4dff;
--violet-d: #5e35b1;
--orange: #ff8a3d;
--orange-d: #f06c1d;
--ink: #3a2e4d;
--muted: #8a7ea0;
--ok: #2e9e5b;
--err: #d8324b;
--line: #ece3f6;
--radius: 18px;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font-family: "Comic Sans MS", "Trebuchet MS", system-ui, sans-serif;
font-size: 17px;
line-height: 1.4;
}
.wrap { max-width: 700px; margin: 0 auto; padding: 16px clamp(12px, 4vw, 24px) 60px; }
header { text-align: center; padding: 18px 0 8px; }
header h1 { margin: 0; font-size: 2rem; color: var(--violet-d); }
header .sub { color: var(--orange-d); font-size: 1.1rem; font-weight: bold; }
.tabs { display: flex; gap: 8px; margin: 18px 0; }
.tabs button {
flex: 1; padding: 14px 8px; font-size: 1.05rem; cursor: pointer;
border: none; border-radius: var(--radius); background: #efe7fb;
color: var(--violet-d); font-family: inherit; font-weight: bold;
transition: transform .08s ease;
}
.tabs button:active { transform: scale(0.97); }
.tabs button.active { background: var(--violet); color: #fff; box-shadow: 0 4px 12px rgba(124,77,255,.35); }
section.panel { display: none; }
section.panel.active { display: block; }
.card {
background: var(--card); border: 2px solid var(--line); border-radius: var(--radius);
padding: 16px; margin: 12px 0; box-shadow: 0 3px 10px rgba(124,77,255,.06);
}
.card h3 { margin: 0 0 10px; color: var(--violet-d); }
.row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.row.between { justify-content: space-between; }
.grow { flex: 1 1 auto; min-width: 0; }
label { display: block; margin: 8px 0 4px; color: var(--muted); font-size: .95rem; }
input[type=text], input[type=url], input[type=number], input[type=file] {
width: 100%; padding: 12px; font-size: 1rem; font-family: inherit;
border: 2px solid var(--line); border-radius: 12px; background: #fbf8ff; color: var(--ink);
}
input:focus { outline: none; border-color: var(--violet); }
button.btn {
padding: 13px 18px; font-size: 1rem; font-family: inherit; font-weight: bold;
border: none; border-radius: 14px; cursor: pointer; color: #fff; background: var(--violet);
transition: transform .08s ease, opacity .15s ease;
}
button.btn:active { transform: scale(0.97); }
button.btn.orange { background: var(--orange); }
button.btn.ghost { background: #efe7fb; color: var(--violet-d); }
button.btn:disabled { opacity: .45; cursor: not-allowed; }
button.icon {
border: none; background: transparent; cursor: pointer; font-size: 1.3rem;
padding: 6px 8px; border-radius: 10px; line-height: 1;
}
button.icon:active { background: #f3eafe; }
.feedname { font-weight: bold; font-size: 1.1rem; }
.badge {
display: inline-block; background: #e3f6e9; color: var(--ok);
border-radius: 10px; padding: 2px 8px; font-size: .8rem; font-weight: bold; margin-left: 6px;
}
ul.eps { list-style: none; margin: 12px 0 0; padding: 0; }
ul.eps li { display: flex; align-items: flex-start; gap: 10px; padding: 9px 0; border-top: 1px solid var(--line); }
ul.eps li label { margin: 0; color: var(--ink); flex: 1; cursor: pointer; }
ul.eps input[type=checkbox] { width: 22px; height: 22px; margin-top: 2px; accent-color: var(--violet); flex: none; }
.dur { color: var(--muted); font-size: .85rem; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 10px 6px; border-bottom: 1px solid var(--line); }
th { color: var(--muted); font-size: .85rem; }
td.num { text-align: right; white-space: nowrap; color: var(--muted); }
.fname { word-break: break-all; }
.stat { display: flex; justify-content: space-between; padding: 10px 4px; border-bottom: 1px solid var(--line); }
.stat .k { color: var(--muted); }
.stat .v { font-weight: bold; }
.spinner { display: inline-block; width: 22px; height: 22px; border: 3px solid var(--line);
border-top-color: var(--violet); border-radius: 50%; animation: spin .8s linear infinite; vertical-align: middle; }
@keyframes spin { to { transform: rotate(360deg); } }
.loading { color: var(--muted); padding: 14px 0; }
#banner {
display: none; background: #ffe2e6; color: var(--err); border: 2px solid #f7b9c3;
border-radius: 14px; padding: 12px 14px; margin: 12px 0; font-weight: bold;
}
#banner.show { display: block; }
#toast {
position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%) translateY(140%);
background: var(--violet-d); color: #fff; padding: 14px 22px; border-radius: 16px;
box-shadow: 0 8px 24px rgba(0,0,0,.25); font-weight: bold; max-width: 90vw; text-align: center;
transition: transform .3s ease; z-index: 50;
}
#toast.show { transform: translateX(-50%) translateY(0); }
#toast.err { background: var(--err); }
.empty { color: var(--muted); text-align: center; padding: 18px 0; }
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>🪄 Merlin Studio</h1>
<div class="sub">Lisael Box</div>
</header>
<div id="banner"></div>
<div class="tabs">
<button id="tab-podcasts" class="active" onclick="showTab('podcasts')">🎧 Podcasts</button>
<button id="tab-fichiers" onclick="showTab('fichiers')">📁 Fichiers</button>
<button id="tab-box" onclick="showTab('box')">📦 Box</button>
</div>
<!-- ===== PODCASTS ===== -->
<section id="panel-podcasts" class="panel active">
<div id="feeds"><div class="loading"><span class="spinner"></span> chargement…</div></div>
<div class="card">
<h3>+ Ajouter un flux</h3>
<label for="f-name">Nom de l'émission</label>
<input type="text" id="f-name" placeholder="Une histoire et Oli">
<label for="f-rss">URL du flux RSS</label>
<input type="url" id="f-rss" placeholder="https://…/rss.xml">
<label for="f-n">Nombre d'épisodes récents</label>
<input type="number" id="f-n" value="2" min="1" max="20">
<div class="row" style="margin-top:12px">
<button class="btn" onclick="addFeed()">Ajouter</button>
</div>
</div>
</section>
<!-- ===== FICHIERS ===== -->
<section id="panel-fichiers" class="panel">
<div class="card">
<h3>Fichiers sur le staging</h3>
<div id="files"><div class="loading"><span class="spinner"></span> chargement…</div></div>
</div>
<div class="card">
<h3>⬆️ Ajouter un fichier audio</h3>
<label for="u-file">Fichier audio (MP3)</label>
<input type="file" id="u-file" accept="audio/*">
<label for="u-show">Émission</label>
<input type="text" id="u-show" value="Perso">
<label for="u-title">Titre</label>
<input type="text" id="u-title" placeholder="Mon histoire">
<div class="row" style="margin-top:12px">
<button id="u-btn" class="btn orange" onclick="uploadFile()">Envoyer</button>
</div>
</div>
</section>
<!-- ===== BOX ===== -->
<section id="panel-box" class="panel">
<div class="card">
<h3>État de la box</h3>
<div id="boxinfo"><div class="loading"><span class="spinner"></span> chargement…</div></div>
<div class="row" style="margin-top:14px">
<button id="push-btn" class="btn orange" onclick="pushNow()" disabled>📤 Pousser maintenant</button>
</div>
</div>
</section>
</div>
<div id="toast"></div>
<script>
"use strict";
// ---------- helpers ----------
function $(id) { return document.getElementById(id); }
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
function showBanner(msg) {
const b = $("banner");
b.textContent = "⚠️ " + msg;
b.classList.add("show");
}
function clearBanner() { $("banner").classList.remove("show"); }
let toastTimer = null;
function toast(msg, isErr) {
const t = $("toast");
t.textContent = msg;
t.classList.toggle("err", !!isErr);
t.classList.add("show");
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(function () { t.classList.remove("show"); }, 3200);
}
// All API calls go through here: same-origin credentials (browser handles Basic Auth),
// JSON parsing, and uniform error surfacing in the banner.
async function api(path, opts) {
opts = opts || {};
opts.credentials = "same-origin";
const res = await fetch(path, opts);
let data = null;
const ct = res.headers.get("content-type") || "";
if (ct.indexOf("application/json") !== -1) {
try { data = await res.json(); } catch (e) { data = null; }
}
if (!res.ok) {
const detail = (data && data.error) ? data.error : ("HTTP " + res.status);
const err = new Error(detail);
err.status = res.status;
throw err;
}
return data;
}
function humanSize(n) {
n = Number(n) || 0;
if (n >= 1048576) return (n / 1048576).toFixed(1) + " Mo";
if (n >= 1024) return (n / 1024).toFixed(0) + " Ko";
return n + " o";
}
// ---------- tabs ----------
const TABS = ["podcasts", "fichiers", "box"];
function showTab(name) {
TABS.forEach(function (t) {
$("panel-" + t).classList.toggle("active", t === name);
$("tab-" + t).classList.toggle("active", t === name);
});
if (name === "fichiers") loadFiles();
if (name === "box") loadBox();
}
// ---------- Podcasts ----------
async function loadFeeds() {
const box = $("feeds");
box.innerHTML = '<div class="loading"><span class="spinner"></span> chargement…</div>';
try {
clearBanner();
const feeds = await api("/api/feeds");
if (!feeds || !feeds.length) {
box.innerHTML = '<div class="card empty">Aucun flux pour le moment.</div>';
return;
}
box.innerHTML = "";
feeds.forEach(function (f) {
const card = document.createElement("div");
card.className = "card";
card.innerHTML =
'<div class="row between">' +
'<span class="feedname">' + esc(f.name) + '</span>' +
'<button class="icon" title="Supprimer ce flux">✕</button>' +
'</div>' +
'<div class="row" style="margin-top:10px">' +
'<button class="btn ghost">👀 Voir les épisodes</button>' +
'</div>' +
'<div class="eps-host"></div>';
const delBtn = card.querySelector(".icon");
const seeBtn = card.querySelector(".btn.ghost");
const host = card.querySelector(".eps-host");
delBtn.onclick = function () { removeFeed(f.key, f.name); };
seeBtn.onclick = function () { loadEpisodes(f.key, host, seeBtn); };
box.appendChild(card);
});
} catch (e) {
box.innerHTML = '<div class="card empty">Impossible de charger les flux.</div>';
showBanner("Flux: " + e.message);
}
}
async function addFeed() {
const name = $("f-name").value.trim();
const rss = $("f-rss").value.trim();
const n = parseInt($("f-n").value, 10) || 2;
if (!name || !rss) { toast("Nom et URL requis", true); return; }
// derive a stable key from the name (server keys feeds by this)
let key = name.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 24);
if (!key) key = "flux-" + Date.now();
try {
await api("/api/feeds", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key: key, name: name, rss: rss, n: n }),
});
$("f-name").value = ""; $("f-rss").value = ""; $("f-n").value = "2";
toast("Flux ajouté ✓");
loadFeeds();
} catch (e) {
toast("Erreur: " + e.message, true);
showBanner("Ajout flux: " + e.message);
}
}
async function removeFeed(key, name) {
if (!confirm("Supprimer le flux « " + name + " » ?")) return;
try {
await api("/api/feeds/" + encodeURIComponent(key), { method: "DELETE" });
toast("Flux supprimé");
loadFeeds();
} catch (e) {
toast("Erreur: " + e.message, true);
showBanner("Suppression flux: " + e.message);
}
}
async function loadEpisodes(key, host, btn) {
host.innerHTML = '<div class="loading"><span class="spinner"></span> chargement des épisodes (1-2 s)…</div>';
btn.disabled = true;
try {
clearBanner();
const data = await api("/api/episodes?feed=" + encodeURIComponent(key));
const eps = (data && data.episodes) || [];
if (!eps.length) {
host.innerHTML = '<div class="empty">Aucun épisode trouvé.</div>';
return;
}
const ul = document.createElement("ul");
ul.className = "eps";
eps.forEach(function (ep, i) {
const li = document.createElement("li");
const cbId = "ep_" + key + "_" + i;
const dur = ep.duration ? '<span class="dur"> · ' + esc(ep.duration) + '</span>' : "";
const badge = ep.staged ? '<span class="badge">✓ déjà sur la box</span>' : "";
li.innerHTML =
'<input type="checkbox" id="' + cbId + '"' + (ep.staged ? " disabled" : "") + '>' +
'<label for="' + cbId + '">' + esc(ep.title) + dur + badge + '</label>';
const cb = li.querySelector("input");
cb.dataset.url = ep.url;
cb.dataset.title = ep.title;
cb.dataset.staged = ep.staged ? "1" : "";
ul.appendChild(li);
});
host.innerHTML = "";
host.appendChild(ul);
const action = document.createElement("div");
action.className = "row";
action.style.marginTop = "12px";
const send = document.createElement("button");
send.className = "btn orange";
send.textContent = "✨ Mettre sur la box";
send.onclick = function () { selectEpisodes(key, ul, send); };
action.appendChild(send);
host.appendChild(action);
} catch (e) {
host.innerHTML = '<div class="empty">Erreur de chargement.</div>';
showBanner("Épisodes: " + e.message);
} finally {
btn.disabled = false;
}
}
async function selectEpisodes(key, ul, btn) {
const chosen = [];
ul.querySelectorAll("input[type=checkbox]").forEach(function (cb) {
if (cb.checked && !cb.dataset.staged) {
chosen.push({ url: cb.dataset.url, title: cb.dataset.title });
}
});
if (!chosen.length) { toast("Coche au moins un épisode", true); return; }
btn.disabled = true;
try {
const r = await api("/api/select", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ feed: key, episodes: chosen }),
});
const n = (r && r.queued != null) ? r.queued : chosen.length;
toast(n + " épisode(s) en cours de préparation…");
// transcode runs server-side; refresh the Fichiers list shortly
setTimeout(loadFiles, 3000);
} catch (e) {
toast("Erreur: " + e.message, true);
showBanner("Sélection: " + e.message);
} finally {
btn.disabled = false;
}
}
// ---------- Fichiers ----------
async function loadFiles() {
const box = $("files");
box.innerHTML = '<div class="loading"><span class="spinner"></span> chargement…</div>';
try {
clearBanner();
const files = await api("/api/files");
if (!files || !files.length) {
box.innerHTML = '<div class="empty">Aucun fichier sur le staging.</div>';
return;
}
let html = '<table><thead><tr><th>Nom</th><th>Taille</th><th>Type</th><th></th></tr></thead><tbody>';
files.forEach(function (f) {
html +=
'<tr>' +
'<td class="fname">' + esc(f.name) + '</td>' +
'<td class="num">' + humanSize(f.size) + '</td>' +
'<td>' + esc(f.type) + '</td>' +
'<td class="num"><button class="icon" data-name="' + esc(f.name) + '" title="Supprimer">🗑</button></td>' +
'</tr>';
});
html += '</tbody></table>';
box.innerHTML = html;
box.querySelectorAll("button.icon").forEach(function (b) {
b.onclick = function () { removeFile(b.dataset.name); };
});
} catch (e) {
box.innerHTML = '<div class="empty">Impossible de charger les fichiers.</div>';
showBanner("Fichiers: " + e.message);
}
}
async function removeFile(name) {
if (!confirm("Supprimer « " + name + " » ?")) return;
try {
await api("/api/files/" + encodeURIComponent(name), { method: "DELETE" });
toast("Fichier supprimé");
loadFiles();
} catch (e) {
toast("Erreur: " + e.message, true);
showBanner("Suppression fichier: " + e.message);
}
}
async function uploadFile() {
const input = $("u-file");
const file = input.files && input.files[0];
if (!file) { toast("Choisis un fichier audio", true); return; }
const show = $("u-show").value.trim() || "Perso";
const title = $("u-title").value.trim() || file.name.replace(/\.[^.]+$/, "");
const btn = $("u-btn");
btn.disabled = true;
const oldLabel = btn.textContent;
btn.textContent = "Envoi…";
try {
const buf = await file.arrayBuffer();
const qs = "?show=" + encodeURIComponent(show) + "&title=" + encodeURIComponent(title);
const r = await api("/api/upload" + qs, {
method: "POST",
headers: { "Content-Type": "audio/mpeg" },
body: buf,
});
toast("Envoyé ✓ " + ((r && r.file) ? r.file : ""));
input.value = "";
$("u-title").value = "";
loadFiles();
} catch (e) {
toast("Erreur: " + e.message, true);
showBanner("Upload: " + e.message);
} finally {
btn.disabled = false;
btn.textContent = oldLabel;
}
}
// ---------- Box ----------
async function loadBox() {
const box = $("boxinfo");
const pushBtn = $("push-btn");
box.innerHTML = '<div class="loading"><span class="spinner"></span> chargement…</div>';
pushBtn.disabled = true;
try {
clearBanner();
const data = await api("/api/box");
const b = (data && data.box) || {};
const delta = (data && data.delta) || [];
const have = (b.have || []);
const ip = b.ip || null;
box.innerHTML =
'<div class="stat"><span class="k">Adresse IP</span><span class="v">' + esc(ip || "jamais annoncée") + '</span></div>' +
'<div class="stat"><span class="k">Dernière annonce</span><span class="v">' + esc(b.last_seen || "—") + '</span></div>' +
'<div class="stat"><span class="k">Fichiers sur la box</span><span class="v">' + have.length + '</span></div>' +
'<div class="stat"><span class="k">En attente d\'envoi</span><span class="v">' + delta.length + '</span></div>';
pushBtn.disabled = !ip;
} catch (e) {
box.innerHTML = '<div class="empty">Impossible de lire l\'état de la box.</div>';
showBanner("Box: " + e.message);
}
}
async function pushNow() {
const btn = $("push-btn");
btn.disabled = true;
try {
const r = await api("/api/push", { method: "POST" });
if (r && r.started) {
toast("Envoi démarré (" + (r.delta || 0) + " fichier(s))");
} else {
toast("Rien à envoyer ou envoi déjà en cours");
}
setTimeout(loadBox, 1500);
} catch (e) {
if (e.status === 409) {
toast("La box ne s'est jamais annoncée", true);
} else {
toast("Erreur: " + e.message, true);
showBanner("Push: " + e.message);
}
btn.disabled = false;
}
}
// ---------- init ----------
window.addEventListener("DOMContentLoaded", function () {
loadFeeds();
});
</script>
</body>
</html>