morz-infoboard/server/backend/internal/httpapi/tenant/templates.go
Alwin e077473bf0 feat(ui): Tenant-Dashboard neu gestaltet
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:30:07 +00:00

281 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package tenant
const tenantDashTmpl = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard {{.Tenant.Name}}</title>
<link rel="stylesheet" href="/static/bulma.min.css">
<style>
:root { --morz-red:#E30613; --morz-red-dark:#B8000F; --nav-bg:#1A1A2E; --bg:#F7F8FA; --surface:#FFFFFF; --shadow-sm:0 1px 4px rgba(0,0,0,.08); --shadow-md:0 4px 16px rgba(0,0,0,.12); --radius:8px; --radius-btn:6px; }
body { background:var(--bg); font-family:system-ui,-apple-system,"Segoe UI",sans-serif; min-height:100vh; }
.navbar { background:var(--nav-bg) !important; }
.navbar-item { color:rgba(255,255,255,.85) !important; }
.morz-brand .accent { color:var(--morz-red); font-weight:800; }
.box { border-radius:var(--radius); box-shadow:var(--shadow-sm); }
.button.is-primary { background:var(--morz-red) !important; border-color:var(--morz-red) !important; border-radius:var(--radius-btn); }
.button.is-primary:hover { background:var(--morz-red-dark) !important; }
.button { border-radius:var(--radius-btn) !important; }
.input:focus,.select select:focus { border-color:var(--morz-red); box-shadow:0 0 0 3px rgba(227,6,19,.12); outline:none; }
/* Tabs */
.tabs li.is-active a { border-bottom:3px solid var(--morz-red) !important; color:var(--morz-red) !important; font-weight:600; }
.tabs a { border-bottom:3px solid transparent; color:#374151; }
.tab-content { display:none; }
.tab-content.is-active { display:block; }
/* Screen cards */
.sc-card { background:var(--surface); border-radius:var(--radius); box-shadow:var(--shadow-sm); padding:1.25rem; display:flex; flex-direction:column; gap:.75rem; transition:box-shadow .15s; }
.sc-card:hover { box-shadow:var(--shadow-md); }
.sc-name { font-weight:700; font-size:1.05rem; display:flex; align-items:center; gap:.5rem; }
.sc-sub { font-size:.8rem; color:#6b7280; display:flex; align-items:center; gap:.5rem; }
.status-dot { width:10px; height:10px; border-radius:50%; display:inline-block; flex-shrink:0; }
.status-dot.online { background:#22c55e; }
.status-dot.stale { background:#f59e0b; }
.status-dot.offline{ background:#ef4444; }
.status-dot.unknown{ background:#9ca3af; }
/* Upload zone */
.upload-zone { border:2px dashed #d1d5db; border-radius:var(--radius); padding:1.5rem; text-align:center; cursor:pointer; transition:border-color .15s; }
.upload-zone:hover,.upload-zone.dragover { border-color:var(--morz-red); background:#fff5f5; }
.upload-zone p { color:#9ca3af; font-size:.875rem; margin:.25rem 0; }
/* Toast */
.morz-toast { position:fixed; top:1rem; right:1rem; z-index:9999; max-width:380px; border-radius:24px; box-shadow:var(--shadow-md); padding:.75rem 1.25rem; display:flex; align-items:center; gap:.75rem; font-size:.9rem; transform:translateX(120%); transition:transform .25s ease; }
.morz-toast.show { transform:translateX(0); }
.morz-toast.is-success { background:#f0fdf4; color:#166534; border:1px solid #bbf7d0; }
.morz-toast.is-danger { background:#fef2f2; color:#991b1b; border:1px solid #fecaca; }
</style>
</head>
<body>
<nav class="navbar" role="navigation">
<div class="navbar-brand">
<span class="navbar-item morz-brand"><span class="accent">MORZ</span> Infoboard</span>
</div>
<div class="navbar-menu">
<div class="navbar-end">
<div class="navbar-item">
<form method="POST" action="/logout">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button class="button is-outlined is-small" style="color:rgba(255,255,255,.85);border-color:rgba(255,255,255,.35)" type="submit">Abmelden</button>
</form>
</div>
</div>
</div>
</nav>
<section class="section">
<div class="container">
<h1 class="title is-4 mb-4">{{.Tenant.Name}}</h1>
{{if .Flash}}
<div class="notification is-success is-light mb-4" role="alert">
<button class="delete" onclick="this.parentElement.remove()"></button>{{.Flash}}
</div>
{{end}}
<div class="tabs mb-0" id="dash-tabs">
<ul>
<li class="is-active" data-tab="monitors"><a>Meine Monitore</a></li>
<li data-tab="media"><a>Mediathek</a></li>
</ul>
</div>
<!-- Tab: Monitors -->
<div id="tab-monitors" class="tab-content is-active">
<div class="box" style="border-radius:0 var(--radius) var(--radius) var(--radius)">
{{if .Screens}}
<div class="columns is-multiline">
{{range .Screens}}
<div class="column is-4-desktop is-6-tablet">
<div class="sc-card">
<div class="sc-name">
{{if eq .Orientation "portrait"}}📱{{else}}🖥{{end}}
{{.Name}}
</div>
<div class="sc-sub">
<span class="status-dot unknown" id="status-{{.Slug}}" title="Unbekannt"></span>
<span>{{orientationLabel .Orientation}}</span>
</div>
<a class="button is-primary is-fullwidth" href="/manage/{{.Slug}}?from=tenant">Playlist bearbeiten →</a>
</div>
</div>
{{end}}
</div>
{{else}}
<p class="has-text-grey">Noch keine Monitore zugewiesen.</p>
{{end}}
</div>
</div>
<!-- Tab: Media -->
<div id="tab-media" class="tab-content">
<div class="box" style="border-radius:0 var(--radius) var(--radius) var(--radius)">
<h2 class="title is-5 mb-3">Medium hochladen</h2>
<form id="upload-form" method="POST" action="/tenant/{{.Tenant.Slug}}/upload" enctype="multipart/form-data">
<div class="field">
<label class="label is-small">Typ</label>
<div class="select is-small">
<select name="type" id="upload-type" onchange="toggleTypeFields()">
<option value="image">🖼 Bild</option>
<option value="video">🎬 Video</option>
<option value="pdf">📄 PDF</option>
<option value="web">🌐 Website (URL)</option>
</select>
</div>
</div>
<div class="field">
<label class="label is-small">Titel <span class="has-text-grey">(optional)</span></label>
<input class="input is-small" type="text" name="title" placeholder="Aus Dateinamen abgeleitet">
</div>
<div id="file-field" class="field">
<label class="label is-small">Datei</label>
<div class="upload-zone" onclick="document.getElementById('upload-file').click()" ondragover="event.preventDefault();this.classList.add('dragover')" ondragleave="this.classList.remove('dragover')" ondrop="handleDrop(event)">
<p style="font-size:1.5rem">📂</p>
<p>Klicken oder Datei hierher ziehen</p>
<p id="drop-fn" style="display:none;color:#374151;font-weight:600"></p>
</div>
<input type="file" id="upload-file" name="file" accept="image/*,video/*,application/pdf" style="display:none" onchange="var p=document.getElementById('drop-fn');if(this.files[0]){p.textContent=this.files[0].name;p.style.display=''}">
</div>
<div id="url-field" class="field" style="display:none">
<label class="label is-small">URL</label>
<input class="input is-small" type="url" name="url" placeholder="https://...">
</div>
<div id="upload-progress-wrap" class="field" style="display:none">
<progress id="upload-progress" class="progress is-primary is-small" value="0" max="100">0%</progress>
</div>
<div id="upload-error" class="notification is-danger is-light is-small mt-2" style="display:none"></div>
<div class="field"><button class="button is-primary is-small" type="button" id="upload-btn" onclick="startUpload()">Hochladen</button></div>
</form>
<hr>
<h2 class="title is-5 mb-3">Vorhandene Medien</h2>
{{if .Assets}}
<div style="overflow-x:auto">
<table class="table is-fullwidth is-hoverable">
<thead><tr style="font-size:.75rem;text-transform:uppercase;letter-spacing:.04em;color:#6b7280">
<th>Typ</th><th>Titel</th><th>Größe</th><th></th>
</tr></thead>
<tbody>
{{range .Assets}}
<tr>
<td>{{typeIcon .Type}}</td>
<td>{{.Title}}</td>
<td class="has-text-grey" style="font-size:.8rem">{{if .SizeBytes}}{{humanSize .SizeBytes}}{{else}}{{end}}</td>
<td>
<form method="POST" action="/tenant/{{$.Tenant.Slug}}/media/{{.ID}}/delete"
onsubmit="return confirm('Medium löschen?')">
<button class="button is-small is-danger is-outlined" type="submit">Löschen</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="has-text-grey">Noch keine Medien hochgeladen.</p>
{{end}}
</div>
</div>
</div>
</section>
<script>
// ─── Tab switching ─────────────────────────────────────────────
(function() {
var tabs = document.querySelectorAll('#dash-tabs li[data-tab]');
tabs.forEach(function(tab) {
tab.addEventListener('click', function() {
tabs.forEach(function(t) { t.classList.remove('is-active'); });
tab.classList.add('is-active');
document.querySelectorAll('.tab-content').forEach(function(c) { c.classList.remove('is-active'); });
document.getElementById('tab-' + tab.dataset.tab).classList.add('is-active');
history.replaceState(null, '', '?tab=' + tab.dataset.tab);
});
});
var tp = new URLSearchParams(window.location.search).get('tab');
if (tp) { var t = document.querySelector('#dash-tabs li[data-tab="' + tp + '"]'); if (t) t.click(); }
})();
// ─── Upload type toggle ────────────────────────────────────────
function toggleTypeFields() {
var t = document.getElementById('upload-type').value;
document.getElementById('file-field').style.display = t === 'web' ? 'none' : '';
document.getElementById('url-field').style.display = t === 'web' ? '' : 'none';
}
function handleDrop(e) {
e.preventDefault(); e.currentTarget.classList.remove('dragover');
var inp = document.getElementById('upload-file');
if (inp && e.dataTransfer.files.length) {
inp.files = e.dataTransfer.files;
var p = document.getElementById('drop-fn');
p.textContent = e.dataTransfer.files[0].name; p.style.display = '';
}
}
function startUpload() {
var t = document.getElementById('upload-type').value;
if (t === 'web') { document.getElementById('upload-form').submit(); return; }
var form = document.getElementById('upload-form');
var inp = document.getElementById('upload-file');
var btn = document.getElementById('upload-btn');
var pg = document.getElementById('upload-progress-wrap');
var bar = document.getElementById('upload-progress');
var err = document.getElementById('upload-error');
err.style.display='none';
if (!inp.files || !inp.files.length) { err.textContent='Bitte eine Datei auswählen.';err.style.display='';return; }
var fd = new FormData(form);
var xhr = new XMLHttpRequest();
xhr.upload.onprogress = function(e) { if (e.lengthComputable) bar.value=Math.round(e.loaded/e.total*100); };
xhr.onloadstart = function() { btn.style.display='none'; pg.style.display=''; bar.value=0; };
xhr.onload = function() {
if (xhr.status>=200&&xhr.status<400) { window.location.href=window.location.pathname+'?tab=media&flash=uploaded'; }
else { pg.style.display='none';btn.style.display='';err.textContent='Upload fehlgeschlagen: '+xhr.responseText;err.style.display=''; }
};
xhr.onerror = function() { pg.style.display='none';btn.style.display='';err.textContent='Netzwerkfehler.';err.style.display=''; };
xhr.open('POST', form.action); xhr.send(fd);
}
// ─── Status polling ────────────────────────────────────────────
(function() {
function update() {
fetch('/api/v1/screens/status').then(function(r){return r.ok?r.json():null;}).then(function(data){
if (!data||!data.screens) return;
data.screens.forEach(function(s){
var el = document.getElementById('status-' + s.screen_id);
if (!el) return;
var st = s.derived_state || 'unknown';
el.className = 'status-dot ' + (st==='online'?'online':st==='degraded'?'stale':'offline');
el.title = st;
});
}).catch(function(){});
}
update(); setInterval(update, 30000);
})();
// ─── CSRF injection ────────────────────────────────────────────
function getCsrf() { var m=document.cookie.match('(?:^|; )morz_csrf=([^;]*)');return m?decodeURIComponent(m[1]):''; }
function injectCSRF() {
var t=getCsrf();if(!t)return;
document.querySelectorAll('form[method="POST"],form[method="post"]').forEach(function(f){
if(!f.querySelector('input[name="csrf_token"]')){var i=document.createElement('input');i.type='hidden';i.name='csrf_token';i.value=t;f.appendChild(i);}
});
}
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',injectCSRF);else injectCSRF();
// ─── Toast ─────────────────────────────────────────────────────
function showToast(msg,type){
var t=document.createElement('div');t.className='morz-toast '+(type||'is-success');
t.innerHTML=msg+'<button onclick="this.parentElement.remove()" style="background:none;border:none;cursor:pointer;font-size:1rem;margin-left:auto;opacity:.6">✕</button>';
document.body.appendChild(t);requestAnimationFrame(function(){t.classList.add('show');});
setTimeout(function(){t.classList.remove('show');setTimeout(function(){t.remove();},300);},3500);
}
(function(){
var flash = new URLSearchParams(window.location.search).get('flash');
if (flash==='uploaded') showToast('✓ Medium erfolgreich hochgeladen.');
})();
</script>
</body>
</html>`