reconcile: import current prod state from CT151 (media-cleanup, access admin, ops versioning, newer dashboard)

This commit is contained in:
agw
2026-06-15 12:27:24 +02:00
parent 8f73960fb4
commit d9d5a12df1
14 changed files with 1875 additions and 91 deletions
+57
View File
@@ -0,0 +1,57 @@
{% extends "base.html" %}
{% block title %}Admin — adgang til services{% endblock %}
{% block body %}
<p><a href="/admin" class="muted">← Admin</a></p>
<h1>Adgang til services</h1>
<p class="lead">Styr hvilke brugere og grupper der kan tilgå hver enkelt service.
Klik på en service for at redigere adgangen.</p>
{% if saved %}<div class="flash">{{ saved }}</div>{% endif %}
<div class="filter" style="max-width:22rem">
<span class="mag">⌕</span>
<input id="afilter" type="search" placeholder="Søg services…" autocomplete="off" aria-label="Søg services">
</div>
<div class="grid" id="acards">
{% for a in apps %}
<a class="card" href="/admin/access/{{ a.slug }}"
data-search="{{ ((a.name or '') ~ ' ' ~ a.slug ~ ' ' ~ (a.group or ''))|lower }}">
<div class="head">
<span class="ico">{% if a.icon %}<img src="{{ a.icon }}" alt="">{% else %}{{ (a.name or '?')[0]|upper }}{% endif %}</span>
<span class="name">{{ a.name }}</span>
</div>
<div class="sub" style="white-space:normal">
{% if a.open %}
<span class="tag open">🌐 åben for alle</span>
{% else %}
{% if a.n_groups %}<span class="tag">{{ a.n_groups }} gruppe{{ 'r' if a.n_groups != 1 }}</span>{% endif %}
{% if a.n_users %}<span class="tag">{{ a.n_users }} bruger{{ 'e' if a.n_users != 1 }}</span>{% endif %}
{% if a.n_other %}<span class="tag muted" title="policy-/negate-bindings styres i Authentik">+{{ a.n_other }} regel</span>{% endif %}
{% endif %}
</div>
<div class="go">Redigér adgang →</div>
</a>
{% endfor %}
</div>
<p id="anomatch" class="muted" style="display:none;margin-top:1rem">Ingen services matcher.</p>
<style>
.tag { display:inline-block; font-size:.74rem; border:1px solid var(--border); border-radius:999px;
padding:.08rem .55rem; margin:.15rem .25rem .15rem 0; color:var(--muted); }
.tag.open { color:#3ecf8e; border-color:color-mix(in srgb,#3ecf8e 40%,transparent);
background:color-mix(in srgb,#3ecf8e 10%,transparent); }
</style>
<script>
(function(){
var inp=document.getElementById('afilter'); if(!inp) return;
var cards=[].slice.call(document.querySelectorAll('#acards .card'));
var nm=document.getElementById('anomatch');
inp.addEventListener('input',function(){
var q=inp.value.trim().toLowerCase(), shown=0;
cards.forEach(function(c){ var hit=!q||c.getAttribute('data-search').indexOf(q)>-1;
c.classList.toggle('hidden',!hit); if(hit) shown++; });
nm.style.display=shown?'none':'block';
});
})();
</script>
{% endblock %}
+123
View File
@@ -0,0 +1,123 @@
{% extends "base.html" %}
{% block title %}Adgang — {{ app.name }}{% endblock %}
{% macro gcard(g) %}
<label class="ucard" data-search="{{ g.name|lower }}">
<input type="checkbox" name="groups" value="{{ g.pk }}" {% if g.pk|string in app.groups %}checked{% endif %}>
<span class="uini">👥</span>
<span class="ubody">
<span class="uname">{{ g.name }}{% if g.pk|string == admin_pk %} <span class="muted" style="font-weight:400">· admin</span>{% endif %}</span>
<span class="usub">{{ g.members }} medlem{{ 'mer' if g.members != 1 }}</span>
</span>
<span class="uchk">✓</span>
</label>
{% endmacro %}
{% macro pcard(u) %}
<label class="ucard" data-search="{{ (u.username ~ ' ' ~ (u.name or '') ~ ' ' ~ (u.email or ''))|lower }}">
<input type="checkbox" name="users" value="{{ u.pk }}" {% if u.pk|string in app.users %}checked{% endif %}>
<span class="uini">{{ (u.username or '?')[0]|upper }}</span>
<span class="ubody">
<span class="uname">{{ u.name or u.username }}</span>
<span class="usub" title="{{ u.username }}{% if u.email %} · {{ u.email }}{% endif %}">{{ u.username }}{% if u.email %} · {{ u.email }}{% endif %}</span>
</span>
<span class="uchk">✓</span>
</label>
{% endmacro %}
{% block body %}
<p><a href="/admin/access" class="muted">← Adgang til services</a></p>
<h1>{{ app.name }}</h1>
<p class="lead">Vælg de grupper og brugere der skal have adgang.
<strong>Ingen markering = åben for alle</strong> autentificerede brugere.</p>
{% if not app.groups and not app.users and not app.n_other %}
<div class="err">Denne service er i dag <strong>åben for alle</strong> der er logget ind.
Markerer du nogen her, bliver adgangen <strong>begrænset</strong> til kun de valgte.</div>
{% endif %}
{% if app.n_other %}
<div class="err">Bemærk: servicen har {{ app.n_other }} ekstra regel (policy/negate) som styres i Authentik
og ikke kan ændres her — de bevares uændret.</div>
{% endif %}
{% set people = users|rejectattr('type','in',['service_account','internal_service_account'])|list %}
{% set svcs = users|selectattr('type','in',['service_account','internal_service_account'])|list %}
<form method="post" action="/admin/access/{{ app.slug }}" id="accform"
data-sensitive="{{ '1' if sensitive else '0' }}"
data-orig="{{ (orig_groups + orig_users)|join(',') }}">
<div class="filter" style="max-width:22rem">
<span class="mag">⌕</span>
<input id="ufilter" type="search" placeholder="Søg grupper og brugere…" autocomplete="off" aria-label="Søg">
</div>
<h2>Grupper</h2>
<div class="ucards" data-pool>
{% for g in groups %}{{ gcard(g) }}{% endfor %}
</div>
<h2>Enkeltbrugere</h2>
<div class="ucards" data-pool>
{% for u in people %}{{ pcard(u) }}{% endfor %}
</div>
{% if svcs %}
<details style="margin-top:1rem">
<summary class="muted" style="cursor:pointer">Service-konti ({{ svcs|length }})</summary>
<div class="ucards" data-pool>{% for u in svcs %}{{ pcard(u) }}{% endfor %}</div>
</details>
{% endif %}
<p style="margin-top:1.4rem"><button class="btn" type="submit">Gem adgang</button>
<a href="/admin/access" class="btn secondary">Annullér</a></p>
</form>
<!-- bekræft-modal: vises kun når en følsom (kun-mig / kun-admins) service udvides -->
<div id="confirm" class="modal-overlay" hidden>
<div class="modal-box" role="dialog" aria-modal="true" aria-labelledby="cf-h">
<h3 id="cf-h" style="margin:.2rem 0 .6rem">Udvide adgangen?</h3>
<p class="muted" style="margin:0 0 1.2rem">Denne service er i dag begrænset til
{% if sensitive %}{{ 'kun administratorer' if app.groups else 'kun dig' }}{% endif %}.
Du er ved at give <strong>flere</strong> adgang. Er du sikker?</p>
<div style="display:flex;gap:.6rem;justify-content:flex-end">
<button type="button" class="btn secondary" id="cf-cancel">Annullér</button>
<button type="button" class="btn danger" id="cf-ok">Bekræft</button>
</div>
</div>
</div>
<style>
.btn.danger { background:#e5484d; color:#fff; }
.btn.danger:hover { filter:brightness(1.08); }
.modal-overlay { position:fixed; inset:0; z-index:120; display:flex; align-items:center; justify-content:center;
background:color-mix(in srgb,var(--bg) 55%,transparent); backdrop-filter:blur(4px); padding:1rem; }
.modal-overlay[hidden] { display:none; }
.modal-box { width:min(28rem,calc(100vw - 2rem)); background:var(--surface); border:1px solid var(--border);
border-radius:1rem; padding:1.4rem 1.5rem; box-shadow:0 24px 64px rgb(0 0 0 / .45); }
</style>
<script>
(function(){
var form=document.getElementById('accform'),
inp=document.getElementById('ufilter'),
modal=document.getElementById('confirm');
// filter
if(inp){ var cards=[].slice.call(document.querySelectorAll('[data-pool] .ucard'));
inp.addEventListener('input',function(){ var q=inp.value.trim().toLowerCase();
cards.forEach(function(c){ c.classList.toggle('hidden', !!q && c.getAttribute('data-search').indexOf(q)<0); }); }); }
// confirm-on-widen
var sensitive=form.getAttribute('data-sensitive')==='1';
var orig=(form.getAttribute('data-orig')||'').split(',').filter(Boolean);
var origSet={}; orig.forEach(function(v){ origSet[v]=1; });
function selected(){ return [].slice.call(form.querySelectorAll('input:checked')).map(function(i){return i.value;}); }
function widened(){ return selected().some(function(v){ return !origSet[v]; }); }
var confirmed=false;
form.addEventListener('submit',function(e){
if(confirmed || !sensitive || !widened()) return; // normal submit
e.preventDefault(); modal.hidden=false;
});
document.getElementById('cf-cancel').addEventListener('click',function(){ modal.hidden=true; });
modal.addEventListener('click',function(e){ if(e.target===modal) modal.hidden=true; });
document.getElementById('cf-ok').addEventListener('click',function(){
confirmed=true; modal.hidden=true; form.submit(); });
})();
</script>
{% endblock %}
+257
View File
@@ -0,0 +1,257 @@
{% extends "base.html" %}
{% block title %}Admin — medie-oprydning{% endblock %}
{% block body %}
<h1>Medie-oprydning</h1>
<p class="lead">Finder film & serier, der <strong>aldrig er set</strong> af nogen og blev tilføjet for længe siden,
og sletter dem automatisk efter en karensperiode — medmindre du beholder dem. Behold via Emby-favorit,
<code>keep</code>-tag i Sonarr/Radarr, hele samlinger eller knappen her; alle vises samlet.</p>
{% if saved %}<div class="flash">{{ saved }}</div>{% endif %}
{% if unconfigured %}
<div class="err">Ikke konfigureret endnu. Sæt <code>EMBY_URL/EMBY_API_KEY</code>,
<code>RADARR_URL/RADARR_API_KEY</code> og <code>SONARR_URL/SONARR_API_KEY</code> i <code>/etc/portal.env</code>.</div>
{% else %}
{% if not watch_ok %}<div class="err">⚠ Emby svarede ikke — se-data mangler, så intet markeres som kandidat (sikkerhed).</div>{% endif %}
{# ---- topkort: disk + status ---- #}
{% set cands = items | selectattr('status','equalto','candidate') | list %}
{% set leavingrows = items | selectattr('delete_after') | list %}
{% set cand_size = cands | sum(attribute='size') %}
<div class="grid" style="grid-template-columns:repeat(auto-fill,minmax(15rem,1fr));margin-top:1.4rem">
<div class="stat">
<div class="muted" style="font-size:.8rem;text-transform:uppercase;letter-spacing:.08em">Slow-lager</div>
{% if disk.ok %}
<div class="n">{{ disk.used_pct }}%</div>
<div class="bar"><span style="width:{{ disk.used_pct }}%;{% if disk.used_pct >= settings.disk_high %}background:#f87171{% endif %}"></span></div>
<div class="muted" style="font-size:.85rem;margin-top:.4rem">{{ human(disk.free) }} fri af {{ human(disk.total) }}</div>
{% else %}<div class="muted">utilgængelig</div>{% endif %}
</div>
<div class="stat"><div class="muted" style="font-size:.8rem;text-transform:uppercase;letter-spacing:.08em">Kandidater nu</div>
<div class="n">{{ cands|length }}</div>
<div class="muted" style="font-size:.85rem;margin-top:.4rem">frigør {{ human(cand_size) }}</div></div>
<div class="stat"><div class="muted" style="font-size:.8rem;text-transform:uppercase;letter-spacing:.08em">På vej ud</div>
<div class="n">{{ leavingrows|length }}</div>
<div class="muted" style="font-size:.85rem;margin-top:.4rem">i karens</div></div>
<div class="stat"><div class="muted" style="font-size:.8rem;text-transform:uppercase;letter-spacing:.08em">Sletning</div>
<div class="n" style="font-size:1.4rem;margin-top:.4rem">{% if settings.enabled %}<span style="color:#3ecf8e">AKTIV</span>{% else %}<span style="color:#f5a623">dry-run</span>{% endif %}</div>
<div class="muted" style="font-size:.85rem;margin-top:.4rem">{% if settings.disk_throttle %}kun over {{ settings.disk_high }}%{% else %}altid efter karens{% endif %}</div></div>
</div>
{# ---- indstillinger (redigerbare defaults) ---- #}
<details style="margin-top:1.6rem" {% if not settings.enabled %}open{% endif %}>
<summary style="cursor:pointer;font-weight:600">⚙️ Indstillinger</summary>
<form method="post" action="/admin/media/settings" style="margin-top:1rem">
<div class="setgrid">
<label class="sw"><input type="checkbox" name="enabled" {% if settings.enabled %}checked{% endif %}> <span>Slet automatisk (ellers kun dry-run)</span></label>
<label class="sw"><input type="checkbox" name="disk_throttle" {% if settings.disk_throttle %}checked{% endif %}> <span>Slet kun når lageret er fyldt</span></label>
<label class="sw"><input type="checkbox" name="do_movies" {% if settings.do_movies %}checked{% endif %}> <span>Inkludér film</span></label>
<label class="sw"><input type="checkbox" name="do_series" {% if settings.do_series %}checked{% endif %}> <span>Inkludér serier</span></label>
</div>
<div class="setgrid" style="margin-top:.8rem">
<label class="fld"><span>Alder før kandidat (dage)</span><input type="number" name="age_days" min="1" max="3650" value="{{ settings.age_days }}"></label>
<label class="fld"><span>Karens før sletning (dage)</span><input type="number" name="grace_days" min="0" max="365" value="{{ settings.grace_days }}"></label>
<label class="fld"><span>Start sletning over (% fyldt)</span><input type="number" name="disk_high" min="5" max="100" value="{{ settings.disk_high }}"></label>
<label class="fld"><span>Stop sletning under (% fyldt)</span><input type="number" name="disk_low" min="1" max="99" value="{{ settings.disk_low }}"></label>
</div>
<p style="margin-top:1rem;display:flex;gap:.6rem;flex-wrap:wrap">
<button class="btn" type="submit">Gem indstillinger</button>
<button class="btn secondary" type="submit" formaction="/admin/media/run" formmethod="post">Kør simulering nu</button>
</p>
</form>
</details>
{# ---- samlinger: behold hele samlinger (også fremtidige film) ---- #}
{% set kept_colls = collections | selectattr('kept') | list %}
<details style="margin-top:1rem">
<summary style="cursor:pointer;font-weight:600">📦 Samlinger — behold hele samlinger{% if kept_colls %} ({{ kept_colls|length }} valgt){% endif %}</summary>
<p class="muted" style="margin:.6rem 0;font-size:.9rem">Markér en samling, så <em>alle</em> dens film — også dem du tilføjer senere — aldrig slettes.</p>
<div class="filter" style="max-width:22rem;margin:0 0 .4rem"><span class="mag">⌕</span><input id="cfilter" type="search" placeholder="Søg samling…" autocomplete="off"></div>
<div id="clist" class="clist"></div>
</details>
<script id="cdata" type="application/json">{{ collections|tojson }}</script>
{# ---- filter ---- #}
<div class="opstools" style="margin-top:1.8rem">
<div class="filter" style="margin:0;flex:1;min-width:11rem">
<span class="mag">⌕</span>
<input id="mfilter" type="search" placeholder="Søg titel…" autocomplete="off">
</div>
<div class="segmented" id="mmode" role="tablist">
<button type="button" data-f="candidate" class="on">Kandidater</button>
<button type="button" data-f="leaving">På vej ud</button>
<button type="button" data-f="kept">Beholdt</button>
<button type="button" data-f="all">Alle</button>
</div>
</div>
<table style="margin-top:1rem">
<thead><tr><th>Titel</th><th style="width:64px">Type</th><th style="width:90px">Størrelse</th><th style="width:104px">Tilføjet</th><th style="width:170px">Status</th><th style="width:96px"></th></tr></thead>
<tbody id="mtbody"></tbody>
</table>
<div id="mpager" class="pager"></div>
<p id="mnomatch" class="muted" style="display:none;margin-top:1rem">Ingen titler matcher.</p>
<script id="mdata" type="application/json">{{ items|tojson }}</script>
{% if log %}
<h2>Seneste sletninger</h2>
<table><tbody>
{% for l in log %}<tr><td>{{ l.title }}</td><td style="width:64px">{{ 'Film' if l.type=='movie' else 'Serie' }}</td><td style="width:90px">{{ human(l.size) }}</td></tr>{% endfor %}
</tbody></table>
{% endif %}
<style>
.bar { height:.5rem; background:var(--surface2); border-radius:999px; overflow:hidden; margin-top:.5rem; }
.bar span { display:block; height:100%; background:var(--accent); border-radius:999px; }
.setgrid { display:grid; gap:.7rem; grid-template-columns:repeat(auto-fill,minmax(15rem,1fr)); }
.sw { display:flex; align-items:center; gap:.55rem; cursor:pointer; }
.sw input { width:auto; }
.fld { display:flex; flex-direction:column; gap:.3rem; font-size:.85rem; color:var(--muted); }
.fld input { width:100%; }
.mbadge { display:inline-block; font-size:.74rem; font-weight:600; border-radius:999px; padding:.1rem .6rem; border:1px solid var(--border); white-space:nowrap; }
.mbadge.amber { color:#f5a623; border-color:color-mix(in srgb,#f5a623 50%,transparent); background:color-mix(in srgb,#f5a623 12%,transparent); }
.mbadge.red { color:#f87171; border-color:color-mix(in srgb,#f87171 55%,transparent); background:color-mix(in srgb,#f87171 14%,transparent); }
.mbadge.green { color:#3ecf8e; border-color:color-mix(in srgb,#3ecf8e 40%,transparent); background:color-mix(in srgb,#3ecf8e 10%,transparent); }
.mbadge.muted { color:var(--muted); }
.segmented { display:inline-flex; border:1px solid var(--border); border-radius:.7rem; overflow:hidden; }
.segmented button { background:transparent; color:var(--muted); border:0; padding:.5rem .9rem; cursor:pointer; font:inherit; font-size:.85rem; }
.segmented button.on { background:var(--surface2); color:var(--text); }
.opstools { display:flex; gap:.8rem; align-items:center; flex-wrap:wrap; }
.pager { display:flex; gap:.9rem; align-items:center; margin-top:1rem; }
.pager button { padding:.35rem .8rem; }
.pager button:disabled { opacity:.4; cursor:default; }
td .btn[disabled] { opacity:.6; cursor:default; }
.clist { display:flex; flex-direction:column; gap:.4rem; margin-top:.4rem; max-height:24rem; overflow:auto; }
.crow { display:flex; align-items:center; gap:.8rem; padding:.45rem .7rem; border:1px solid var(--border); border-radius:.7rem; background:color-mix(in srgb,var(--surface) 70%,transparent); }
.crow.on { border-color:color-mix(in srgb,#3ecf8e 45%,transparent); background:color-mix(in srgb,#3ecf8e 8%,transparent); }
.crow .cname { flex:1; font-weight:600; font-size:.9rem; min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.crow .ccount { font-size:.8rem; flex:0 0 auto; }
</style>
<script>
(function(){
var all = JSON.parse(document.getElementById('mdata').textContent || '[]');
var colls = JSON.parse(document.getElementById('cdata').textContent || '[]');
var now = {{ now|default(0) }};
var PAGE = 25, st = { q:'', mode:'candidate', page:1 };
var tb = document.getElementById('mtbody'), pager = document.getElementById('mpager'),
nomatch = document.getElementById('mnomatch'), clist = document.getElementById('clist');
function esc(s){ return String(s).replace(/[&<>"]/g, function(c){
return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]; }); }
function human(n){ var u=['B','KB','MB','GB','TB'], i=0; while(n>=1024 && i<4){ n/=1024; i++; }
return n.toFixed(1)+' '+u[i]; }
function leaving(it){ return it.delete_after != null; }
// ---- hovedtabel ----
function match(it){
var okq = !st.q || it.title.toLowerCase().indexOf(st.q) > -1;
var okm = st.mode==='all'
|| (st.mode==='candidate' && it.status==='candidate' && !leaving(it))
|| (st.mode==='leaving' && leaving(it))
|| (st.mode==='kept' && it.kept);
return okq && okm;
}
function badge(it){
if (leaving(it)) return '<span class="mbadge red">På vej ud · '+Math.ceil((it.delete_after-now)/86400)+'d</span>';
if (it.status==='candidate') return '<span class="mbadge amber">Kandidat</span>';
if (it.status==='kept') return '<span class="mbadge green">Beholdt'+((it.keep_sources&&it.keep_sources.length)?' · '+esc(it.keep_sources.join(', ')):'')+'</span>';
if (it.status==='watched') return '<span class="mbadge muted">Set</span>';
if (it.status==='tooNew') return '<span class="mbadge muted">For ny</span>';
return '<span class="mbadge muted">Ingen fil</span>';
}
function row(it){
var btn = it.kept
? '<button class="btn secondary keepbtn" data-key="'+esc(it.key)+'" data-on="0" style="padding:.35rem .7rem;font-size:.82rem">Frigiv</button>'
: '<button class="btn keepbtn" data-key="'+esc(it.key)+'" data-on="1" style="padding:.35rem .7rem;font-size:.82rem">Behold</button>';
return '<tr><td>'+esc(it.title)+'</td><td>'+(it.type==='movie'?'Film':'Serie')+'</td><td>'+human(it.size)+'</td>'+
'<td class="muted" style="font-size:.85rem">'+esc((it.added||'').slice(0,10))+'</td><td>'+badge(it)+'</td><td>'+btn+'</td></tr>';
}
function render(){
var list = all.filter(match);
var pages = Math.max(1, Math.ceil(list.length/PAGE));
if (st.page > pages) st.page = pages;
tb.innerHTML = list.slice((st.page-1)*PAGE, st.page*PAGE).map(row).join('');
nomatch.style.display = list.length ? 'none' : 'block';
pager.innerHTML = list.length > PAGE
? '<button class="btn secondary" data-pg="prev"'+(st.page<=1?' disabled':'')+'>‹</button>'+
'<span class="muted">Side '+st.page+' af '+pages+' · '+list.length+' titler</span>'+
'<button class="btn secondary" data-pg="next"'+(st.page>=pages?' disabled':'')+'>›</button>'
: (list.length ? '<span class="muted">'+list.length+' titler</span>' : '');
}
document.getElementById('mfilter').addEventListener('input', function(e){
st.q = e.target.value.trim().toLowerCase(); st.page = 1; render(); });
document.getElementById('mmode').addEventListener('click', function(e){
var b = e.target.closest('button[data-f]'); if (!b) return;
st.mode = b.getAttribute('data-f'); st.page = 1;
this.querySelectorAll('button').forEach(function(x){ x.classList.toggle('on', x===b); });
render();
});
pager.addEventListener('click', function(e){
var b = e.target.closest('button[data-pg]'); if (!b || b.disabled) return;
st.page += b.getAttribute('data-pg')==='next' ? 1 : -1; render();
});
tb.addEventListener('click', function(e){
var b = e.target.closest('.keepbtn'); if (!b) return;
var key = b.getAttribute('data-key'), on = b.getAttribute('data-on')==='1';
b.disabled = true; b.textContent = '…';
fetch('/api/media/keep', { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ key:key, on:on }) })
.then(function(r){ return r.json(); })
.then(function(res){
if (!res || !res.ok) throw 0;
var it = all.filter(function(x){ return x.key===key; })[0]; if (!it) return;
var hadFav = (it.keep_sources||[]).indexOf('favorit') > -1;
var hadColl = (it.keep_sources||[]).indexOf('samling') > -1;
if (on){ var s=[]; if(hadFav)s.push('favorit'); if(hadColl)s.push('samling'); s.push('tag','portal');
it.keep_sources=s; it.kept=true; it.delete_after=null; }
else { var s2=[]; if(hadFav)s2.push('favorit'); if(hadColl)s2.push('samling');
it.keep_sources=s2; it.kept=s2.length>0; }
it.status = it.kept ? 'kept' : it.base_status;
render();
})
.catch(function(){ b.disabled = false; b.textContent = on?'Behold':'Frigiv';
alert('Kunne ikke gemme — prøv igen.'); });
});
// ---- samlinger ----
var cq = '';
function crow(c){
var btn = c.kept
? '<button class="btn secondary collbtn" data-tmdb="'+esc(c.tmdb)+'" data-title="'+esc(c.title)+'" data-on="0" style="padding:.3rem .7rem;font-size:.82rem">Beholdes</button>'
: '<button class="btn collbtn" data-tmdb="'+esc(c.tmdb)+'" data-title="'+esc(c.title)+'" data-on="1" style="padding:.3rem .7rem;font-size:.82rem">Behold</button>';
return '<div class="crow'+(c.kept?' on':'')+'"><span class="cname">'+esc(c.title)+'</span><span class="ccount muted">'+c.count+' film</span>'+btn+'</div>';
}
function crender(){
var list = colls.filter(function(c){ return !cq || c.title.toLowerCase().indexOf(cq) > -1; });
clist.innerHTML = list.length ? list.map(crow).join('') : '<p class="muted">Ingen samlinger.</p>';
}
document.getElementById('cfilter').addEventListener('input', function(e){
cq = e.target.value.trim().toLowerCase(); crender(); });
clist.addEventListener('click', function(e){
var b = e.target.closest('.collbtn'); if (!b) return;
var tmdb = b.getAttribute('data-tmdb'), title = b.getAttribute('data-title'), on = b.getAttribute('data-on')==='1';
b.disabled = true; b.textContent = '…';
fetch('/api/media/collection-keep', { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ tmdb:tmdb, title:title, on:on }) })
.then(function(r){ return r.json(); })
.then(function(res){
if (!res || !res.ok) throw 0;
var c = colls.filter(function(x){ return x.tmdb===tmdb; })[0]; if (c) c.kept = on;
all.forEach(function(it){
if (it.coll !== tmdb) return;
var s = (it.keep_sources||[]).filter(function(x){ return x!=='samling'; });
if (on) s.push('samling');
it.keep_sources = s; it.kept = s.length>0; it.status = it.kept ? 'kept' : it.base_status;
if (it.kept) it.delete_after = null;
});
crender(); render();
})
.catch(function(){ b.disabled = false; b.textContent = on?'Behold':'Beholdes';
alert('Kunne ikke gemme — prøv igen.'); });
});
crender();
render();
})();
</script>
{% endif %}
{% endblock %}
+133 -42
View File
@@ -2,51 +2,142 @@
{% block title %}Admin — drift{% endblock %}
{% block body %}
<h1>Drift / egress</h1>
<p class="wt-lead">Live driftsoverblik. Genindlæser automatisk hvert 20. sekund.</p>
<p class="lead">Live driftsoverblik — versioner, opdateringer og random-proxy.
Opdaterer automatisk hvert 10. sekund i baggrunden.</p>
<h2>Static-assets</h2>
<div class="wt-stat" style="max-width:18rem">
<div class="wt-stat-n">{{ version if version else '—' }}</div>
<div class="wt-muted">deployet version (assets.weircon.dk)</div>
</div>
<h2 style="display:flex;align-items:baseline;gap:.8rem">Random-proxy egress
{% if rp and not rp.error %}<span class="wt-muted" style="font-size:.8rem;letter-spacing:0;text-transform:none">
{{ rp.available }}/{{ rp.proxies_total }} live · {{ rp.inflight_total }} inflight i alt</span>{% endif %}
</h2>
{% if rp.error %}
<div class="wt-flash wt-flash--error">Kunne ikke nå random-proxy: {{ rp.error }}</div>
{% else %}
{% set rot = rp.rotation %}
{% if rot %}
<div class="wt-stat" style="margin-bottom:1rem">
<div class="wt-muted">Næste rollover</div>
<div style="margin-top:.3rem">
<strong>slot {{ rot.next_slot }}</strong>
— <span class="wt-mono">{{ rot.next_egress_ip }}</span>
{% if rot.geo and rot.geo.flag %}<span title="{{ rot.geo.name }}">{{ rot.geo.flag }} {{ rot.geo.iso }}</span>{% endif %}
ruller af om <strong>{{ '%d:%02d'|format(rot.eta_sec//60, rot.eta_sec%60) }}</strong>
<span class="wt-muted">(interval {{ rot.interval_sec }}s · sidst: slot {{ rot.last_slot }} for {{ rot.last_rotated_age_sec }}s siden)</span>
<div class="opstools">
<div class="filter" style="margin:0;flex:1;min-width:11rem">
<span class="mag">⌕</span>
<input id="opsfilter" type="search" placeholder="Søg tjeneste…" autocomplete="off" aria-label="Søg tjeneste">
</div>
<div class="segmented" id="opsmode" role="tablist" aria-label="Filtrér">
<button type="button" data-mode="all" class="on">Alle</button>
<button type="button" data-mode="update">Opdateringer</button>
<button type="button" data-mode="eol">EOL</button>
</div>
<div class="segmented" id="opslevel" role="tablist" aria-label="Niveau">
<button type="button" data-level="all" class="on">Alle niveauer</button>
<button type="button" data-level="major">Major</button>
<button type="button" data-level="minor">Minor</button>
<button type="button" data-level="patch">Bugfix</button>
</div>
</div>
{% endif %}
<div class="wt-grid" style="grid-template-columns:repeat(auto-fill,minmax(190px,1fr))">
{% for p in rp.proxies %}
<div class="slot{% if rot and p.id == rot.next_slot %} next{% endif %}">
<div class="slot-top">
<span class="slot-id">#{{ p.id }}</span>
<span class="slot-state {{ 'down' if not p.reachable else ('busy' if p.inflight else 'live') }}">
{{ '⚠ down' if not p.reachable else (p.state or 'live') }}</span>
</div>
<div class="wt-mono" style="font-size:.92rem;overflow:hidden;text-overflow:ellipsis">{{ p.egress_ip or '–' }}</div>
<div class="wt-muted" style="font-size:.84rem;margin-top:.2rem">
{% if p.geo and p.geo.flag %}{{ p.geo.flag }} {{ p.geo.iso }} · {% endif %}{{ p.inflight or 0 }} inflight
{% if p.latency_ms %}· {{ p.latency_ms }}ms{% endif %}
</div>
{% if rot and p.id == rot.next_slot %}<div class="slot-next">↻ ruller af næst</div>{% endif %}
<div id="opscontent">
<div class="grid" aria-hidden="true" style="margin-top:1.4rem">
{% for i in range(6) %}
<div class="stat" style="opacity:.5"><span class="sk" style="width:60%;height:1.6em;display:block;border-radius:6px"></span>
<span class="sk" style="width:40%;height:.8em;display:block;margin-top:.6rem;border-radius:5px"></span></div>
{% endfor %}
</div>
{% endfor %}
</div>
{% endif %}
<script>setTimeout(function(){ location.reload(); }, 20000);</script>
<style>
.sk { background:var(--surface2); }
.font-mono { font-family: ui-monospace, "Cascadia Code", Menlo, Consolas, monospace; }
.vtable td { vertical-align:middle; }
.vbadge { display:inline-block; font-size:.74rem; font-weight:600; border-radius:999px; padding:.1rem .6rem; border:1px solid var(--border); color:var(--muted); white-space:nowrap; }
.vbadge.ok { color:#3ecf8e; border-color:color-mix(in srgb,#3ecf8e 40%,transparent); background:color-mix(in srgb,#3ecf8e 10%,transparent); }
.vbadge.update { color:#f5a623; border-color:color-mix(in srgb,#f5a623 50%,transparent); background:color-mix(in srgb,#f5a623 12%,transparent); }
.vbadge.muted { color:var(--muted); }
.vbadge.soon { color:#f5a623; border-color:color-mix(in srgb,#f5a623 50%,transparent); background:color-mix(in srgb,#f5a623 12%,transparent); }
.vbadge.eol { color:#f87171; border-color:color-mix(in srgb,#f87171 55%,transparent); background:color-mix(in srgb,#f87171 14%,transparent); font-weight:700; }
/* opdaterings-niveau-farver */
.vbadge.lvl-major { color:#fb923c; border-color:color-mix(in srgb,#fb923c 50%,transparent); background:color-mix(in srgb,#fb923c 12%,transparent); }
.vbadge.lvl-patch { color:var(--muted); border-color:var(--border); background:transparent; }
/* EOL-celle på ÉN linje: kompakt badge + lille næste-major */
.vtable td:last-child { white-space:nowrap; }
.nextmajor { margin-left:.35rem; font-size:.72rem; color:var(--accent); white-space:nowrap; }
.opslead { display:flex; align-items:baseline; gap:.7rem; flex-wrap:wrap; }
/* filter-værktøjslinje (persistent i shell, overlever 10s-poll) */
.opstools { display:flex; gap:.8rem; align-items:center; flex-wrap:wrap; margin:1.2rem 0 .2rem; }
.segmented { display:inline-flex; border:1px solid var(--border); border-radius:.7rem; overflow:hidden; }
.segmented button { background:transparent; color:var(--muted); border:0; padding:.5rem .9rem; cursor:pointer; font:inherit; font-size:.85rem; }
.segmented button.on { background:var(--surface2); color:var(--text); }
/* opdaterings-/EOL-markering på rækker (desktop: tonet baggrund) */
.updrow td, .eolnear td { background:color-mix(in srgb,#f5a623 7%,transparent); }
.eolsoon td { background:color-mix(in srgb,#f5a623 14%,transparent); }
.eolrow td { background:color-mix(in srgb,#f87171 9%,transparent); }
.slot { background:color-mix(in srgb, var(--surface) 70%, transparent); border:1px solid var(--border); border-radius:.9rem; padding:.8rem .9rem; }
.slot.next { border-color:#f5a623; box-shadow:0 0 0 1px color-mix(in srgb,#f5a623 40%,transparent); }
.slot-top { display:flex; justify-content:space-between; align-items:center; margin-bottom:.35rem; }
.slot-id { font-weight:700; color:var(--muted); font-size:.85rem; }
.slot-state { font-size:.72rem; border-radius:999px; padding:.05rem .5rem; border:1px solid var(--border); color:var(--muted); }
.slot-state.live { color:#3ecf8e; border-color:color-mix(in srgb,#3ecf8e 40%,transparent); }
.slot-state.busy { color:#f5a623; border-color:color-mix(in srgb,#f5a623 40%,transparent); }
.slot-state.down { color:#f87171; border-color:color-mix(in srgb,#f87171 40%,transparent); }
.slot-next { color:#f5a623; font-size:.74rem; margin-top:.35rem; }
#opscontent.stale { opacity:.55; transition:opacity .3s; }
/* mobil: versionstabellen kollapser til kort (ét pr. tjeneste) */
@media (max-width: 640px) {
.vtable thead { display:none; }
.vtable, .vtable tbody, .vtable tr, .vtable td { display:block; width:100%; }
.vtable tr { border:1px solid var(--border); border-radius:.9rem; padding:.55rem .85rem;
margin-bottom:.7rem; background:color-mix(in srgb,var(--surface) 70%,transparent); }
.vtable tr.updrow, .vtable tr.eolnear { border-color:color-mix(in srgb,#f5a623 50%,transparent); }
/* EOL < 3 mdr: hele kortet gult */
.vtable tr.eolsoon { border-color:color-mix(in srgb,#f5a623 70%,transparent);
background:color-mix(in srgb,#f5a623 22%,transparent); }
.vtable tr.eolrow { border-color:color-mix(in srgb,#f87171 60%,transparent);
background:color-mix(in srgb,#f87171 18%,transparent); }
.vtable tr.eolsoon td:first-child, .vtable tr.eolrow td:first-child { border-bottom-color:color-mix(in srgb,var(--text) 18%,transparent); }
.vtable td { border:0; background:transparent; padding:.2rem 0; display:flex;
justify-content:space-between; gap:1rem; align-items:center; text-align:right; }
.vtable td:first-child { font-size:1.05rem; padding:.1rem 0 .45rem; margin-bottom:.35rem;
border-bottom:1px solid var(--border); }
.vtable td:not(:first-child)::before { content:attr(data-label); color:var(--muted);
font-size:.76rem; font-weight:600; text-align:left; letter-spacing:.02em; }
.vtable td .vbadge { white-space:normal; text-align:right; }
.vtable td:last-child { white-space:normal; }
}
</style>
<script>
(function(){
var box = document.getElementById('opscontent'), busy = false;
var fstate = { q:'', mode:'all', level:'all' };
// Filter ligger i shell (persistent) og genanvendes efter hver poll → fokus/værdi bevares.
function applyFilter(){
var rows = box.querySelectorAll('.vtable tbody tr'), shown = 0;
rows.forEach(function(tr){
var name = tr.getAttribute('data-name') || '',
st = tr.getAttribute('data-status'), lvl = tr.getAttribute('data-level'),
eol = tr.getAttribute('data-eol');
var okq = !fstate.q || name.indexOf(fstate.q) > -1;
var isEol = (eol === 'eol' || eol === 'soon');
var okm = fstate.mode === 'all'
|| (fstate.mode === 'update' && (st === 'update' || isEol))
|| (fstate.mode === 'eol' && isEol);
// niveau-filter rammer kun opdateringer (kun de har data-level)
var okl = fstate.level === 'all' || lvl === fstate.level;
var vis = okq && okm && okl; tr.style.display = vis ? '' : 'none'; if (vis) shown++;
});
var nm = box.querySelector('#opsnomatch');
if (nm) nm.style.display = (rows.length && !shown) ? 'block' : 'none';
}
var fi = document.getElementById('opsfilter');
if (fi) fi.addEventListener('input', function(){ fstate.q = fi.value.trim().toLowerCase(); applyFilter(); });
function wireSeg(id, key){
var bar = document.getElementById(id); if (!bar) return;
bar.addEventListener('click', function(e){
var b = e.target.closest('button[data-' + key + ']'); if (!b) return;
fstate[key] = b.getAttribute('data-' + key);
bar.querySelectorAll('button').forEach(function(x){ x.classList.toggle('on', x === b); });
applyFilter();
});
}
wireSeg('opsmode', 'mode');
wireSeg('opslevel', 'level');
function load(){
if (busy) return; busy = true;
fetch('/ops-content', {headers:{'X-Requested-With':'fetch'}})
.then(function(r){ if(!r.ok) throw 0; return r.text(); })
.then(function(html){ box.innerHTML = html; box.classList.remove('stale'); applyFilter(); })
.catch(function(){ box.classList.add('stale'); })
.finally(function(){ busy = false; });
}
load();
setInterval(load, 10000);
document.addEventListener('visibilitychange', function(){ if(!document.hidden) load(); });
})();
</script>
{% endblock %}
+87
View File
@@ -0,0 +1,87 @@
{# fragment — injiceres i #opscontent og pollers hvert 10s fra admin_ops.html #}
{% set updates = versions|selectattr('status','equalto','update')|list %}
{% set eol_now = versions|selectattr('eol')|selectattr('eol.state','equalto','eol')|list %}
{% set eol_soon = versions|selectattr('eol')|selectattr('eol.state','equalto','soon')|list %}
<div class="opslead" style="margin-top:1.4rem">
<h2 style="margin:0">Versioner & opdateringer</h2>
{% if updates %}<span class="vbadge update">{{ updates|length }} opdatering{{ 'er' if updates|length != 1 }} klar</span>
{% else %}<span class="vbadge ok">alt opdateret</span>{% endif %}
{% if eol_now %}<span class="vbadge eol">{{ eol_now|length }} EOL</span>{% endif %}
{% if eol_soon %}<span class="vbadge soon">{{ eol_soon|length }} EOL snart</span>{% endif %}
</div>
<table class="vtable" style="margin-top:.7rem">
<thead><tr><th>Tjeneste</th><th>Din version</th><th>Nyeste</th><th>Status</th><th>Support (EOL)</th></tr></thead>
<tbody>
{% for v in versions %}
{% set ec = v.eol.state if v.eol else 'none' %}
{% set near3 = v.eol and v.eol.state == 'soon' and v.eol.days is defined and v.eol.days < 92 %}
{% set rowclass = 'eolrow' if ec == 'eol' else ('eolsoon' if near3 else ('eolnear' if ec == 'soon' else ('updrow' if v.status == 'update' else ''))) %}
<tr class="{{ rowclass }}" data-name="{{ (v.name or '')|lower }}" data-status="{{ v.status }}" data-level="{{ v.level or '' }}" data-eol="{{ ec }}">
<td data-label="Tjeneste"><strong>{{ v.name }}</strong></td>
<td class="font-mono" data-label="Din version">{{ v.current or '—' }}</td>
<td class="font-mono muted" data-label="Nyeste">{{ v.latest or '—' }}</td>
<td data-label="Status">
{% if v.status == 'ok' %}<span class="vbadge ok">✓ opdateret</span>
{% elif v.status == 'update' %}<span class="vbadge update lvl-{{ v.level }}" title="{{ v.level or 'opdatering' }}-opdatering">↑ {{ v.level or 'opdatering' }}: {{ v.latest }}</span>
{% elif v.status == 'latest-only' %}<span class="vbadge muted" title="kender kun nyeste udgivelse">nyeste: {{ v.latest or '—' }}</span>
{% else %}<span class="vbadge muted" title="ingen kilde til nyeste version">ukendt</span>{% endif %}
</td>
<td data-label="Support (EOL)">
{% if not v.eol %}<span class="muted">—</span>
{% elif v.eol.state == 'eol' %}<span class="vbadge eol" title="cyklus {{ v.eol.cycle }} er end-of-life">⚠ EOL{% if v.eol.date %} siden {{ v.eol.date }}{% endif %}</span>
{% elif v.eol.state == 'soon' %}<span class="vbadge soon" title="cyklus {{ v.eol.cycle }} udløber snart">EOL {{ v.eol.date }}{% if v.eol.days is defined %} ({{ v.eol.days }}d){% endif %}</span>
{% else %}<span class="vbadge muted" title="cyklus {{ v.eol.cycle }} supporteret">{% if v.eol.date %}✓ til {{ v.eol.date }}{% else %}✓ supporteret{% endif %}</span>{% endif %}
{% if v.eol and v.eol.state in ('eol','soon') and v.eol.next %}<small class="nextmajor" title="anbefalet opgradering: major {{ v.eol.next.cycle }}{% if v.eol.next.eol %} (EOL {{ v.eol.next.eol }}){% endif %}">→ <strong>{{ v.eol.next.cycle }}</strong>{% if v.eol.next.latest %} ({{ v.eol.next.latest }}){% endif %}</small>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<p id="opsnomatch" class="muted" style="display:none;margin:.4rem 0">Ingen tjenester matcher filteret.</p>
<h2>Static-assets</h2>
<div class="stat" style="max-width:18rem">
<div class="n">{{ version if version else '—' }}</div>
<div class="muted">deployet version (assets.weircon.dk)</div>
</div>
<h2 style="display:flex;align-items:baseline;gap:.8rem">Random-proxy egress
{% if rp and not rp.error %}<span class="muted" style="font-size:.8rem;letter-spacing:0;text-transform:none">
{{ rp.available }}/{{ rp.proxies_total }} live · {{ rp.inflight_total }} inflight i alt</span>{% endif %}
</h2>
{% if rp.error %}
<div class="err">Kunne ikke nå random-proxy: {{ rp.error }}</div>
{% else %}
{% set rot = rp.rotation %}
{% if rot %}
<div class="stat" style="margin-bottom:1rem">
<div class="muted">Næste rollover</div>
<div style="margin-top:.3rem">
<strong>slot {{ rot.next_slot }}</strong>
— <span class="font-mono">{{ rot.next_egress_ip }}</span>
{% if rot.geo and rot.geo.flag %}<span title="{{ rot.geo.name }}">{{ rot.geo.flag }} {{ rot.geo.iso }}</span>{% endif %}
ruller af om <strong>{{ '%d:%02d'|format(rot.eta_sec//60, rot.eta_sec%60) }}</strong>
<span class="muted">(interval {{ rot.interval_sec }}s · sidst: slot {{ rot.last_slot }} for {{ rot.last_rotated_age_sec }}s siden)</span>
</div>
</div>
{% endif %}
<div class="grid" style="grid-template-columns:repeat(auto-fill,minmax(190px,1fr))">
{% for p in rp.proxies %}
<div class="slot{% if rot and p.id == rot.next_slot %} next{% endif %}">
<div class="slot-top">
<span class="slot-id">#{{ p.id }}</span>
<span class="slot-state {{ 'down' if not p.reachable else ('busy' if p.inflight else 'live') }}">
{{ '⚠ down' if not p.reachable else (p.state or 'live') }}</span>
</div>
<div class="font-mono" style="font-size:.92rem;overflow:hidden;text-overflow:ellipsis">{{ p.egress_ip or '–' }}</div>
<div class="muted" style="font-size:.84rem;margin-top:.2rem">
{% if p.geo and p.geo.flag %}{{ p.geo.flag }} {{ p.geo.iso }} · {% endif %}{{ p.inflight or 0 }} inflight
{% if p.latency_ms %}· {{ p.latency_ms }}ms{% endif %}
</div>
{% if rot and p.id == rot.next_slot %}<div class="slot-next">↻ ruller af næst</div>{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
+39 -16
View File
@@ -2,13 +2,13 @@
{% block title %}Weircon — forside{% endblock %}
{% block body %}
<h1>Hej, {{ user.name }}</h1>
<p class="wt-lead">Tjenester du har adgang til.</p>
{% if saved %}<div class="wt-flash">{{ saved }}</div>{% endif %}
<p class="lead">Tjenester du har adgang til.</p>
{% if saved %}<div class="flash">{{ saved }}</div>{% endif %}
<div id="dash">
<div class="wt-grid" style="margin-top:1.6rem" aria-hidden="true">
<div class="grid" style="margin-top:1.6rem" aria-hidden="true">
{% for i in range(8) %}
<div class="wt-tile skel"><div class="wt-tile-head"><span class="wt-tile-ico"></span><span class="sk" style="width:55%;height:1em"></span></div><span class="sk" style="width:80%;height:.8em;margin-top:.8rem"></span></div>
<div class="card skel"><div class="head"><span class="ico"></span><span class="sk" style="width:55%;height:1em"></span></div><span class="sk" style="width:80%;height:.8em;margin-top:.8rem"></span></div>
{% endfor %}
</div>
</div>
@@ -16,23 +16,46 @@
{% if is_admin %}
<section class="appgroup">
<h2>Administration</h2>
<div class="wt-grid">
<a class="wt-tile" href="/admin">
<div class="wt-tile-head"><span class="wt-tile-ico">⚙️</span><span class="wt-tile-name">Admin-panel</span></div>
<div class="wt-tile-sub">Brugere, grupper, drift, ikoner m.m.</div>
<div class="wt-tile-go">Åbn →</div>
<div class="grid">
<a class="card" href="/admin" style="position:relative">
<div class="head"><span class="ico">⚙️</span><span class="name">Admin-panel</span></div>
<div class="sub">Brugere, grupper, drift, ikoner m.m.</div>
<div class="go">Åbn →</div>
</a>
</div>
</section>
{% endif %}
<style>
.toolbar { display:flex; gap:1rem; align-items:center; flex-wrap:wrap; margin:1.4rem 0 .2rem; }
.toolbar .filter { margin:0; flex:1; min-width:12rem; }
.viewtoggle { display:inline-flex; border:1px solid var(--border); border-radius:.7rem; overflow:hidden; }
.viewtoggle button { background:transparent; color:var(--muted); border:0; padding:.5rem .9rem; cursor:pointer; font:inherit; font-size:.88rem; }
.viewtoggle button.on { background:var(--surface2); color:var(--text); }
.card { position:relative; }
.card:hover { border-color:var(--accent); transform:translateY(-3px); background:color-mix(in srgb, var(--surface) 92%, transparent); }
.card-link { position:absolute; inset:0; z-index:1; border-radius:inherit; }
.fav { position:absolute; top:.55rem; right:.6rem; z-index:2; width:1.7rem; height:1.7rem; border-radius:.5rem;
border:0; background:transparent; color:var(--border); cursor:pointer; font-size:1.05rem; line-height:1; padding:0; transition:color .15s, transform .15s; }
.fav:hover { color:var(--muted); transform:scale(1.15); }
.fav.on { color:#f5c518; }
.mcdot { display:inline-block; width:.55rem; height:.55rem; border-radius:50%; margin-right:.4rem; }
.mcdot.run { background:#3fb950; box-shadow:0 0 6px rgba(63,185,80,.5); }
.mcdot.off { background:var(--muted); }
.mcdot.warn { background:#f5c518; }
.mcdot.err { background:#f85149; }
.card.skel { opacity:.5; pointer-events:none; }
.card.skel .ico { background:var(--surface2); }
.sk { display:inline-block; background:var(--surface2); border-radius:5px; }
[hidden] { display:none !important; }
</style>
<script>
(function(){
function setView(v){
var g=document.getElementById('view-grouped'), a=document.getElementById('view-alpha');
if(!g||!a) return;
g.hidden=(v==='alpha'); a.hidden=(v!=='alpha');
document.querySelectorAll('.wt-segment button').forEach(function(b){ b.classList.toggle('is-on', b.getAttribute('data-view')===v); });
document.querySelectorAll('.viewtoggle button').forEach(function(b){ b.classList.toggle('on', b.getAttribute('data-view')===v); });
}
function setFav(slug,on){
document.querySelectorAll('.fav[data-slug="'+CSS.escape(slug)+'"]').forEach(function(b){
@@ -40,9 +63,9 @@
});
var favgrid=document.getElementById('favgrid'), favsec=document.getElementById('favsec');
if(!favgrid) return;
var has=favgrid.querySelector('.wt-tile[data-slug="'+CSS.escape(slug)+'"]');
var has=favgrid.querySelector('.card[data-slug="'+CSS.escape(slug)+'"]');
if(on && !has){
var src=document.querySelector('#view-grouped .wt-tile[data-slug="'+CSS.escape(slug)+'"]') || document.querySelector('.wt-tile[data-slug="'+CSS.escape(slug)+'"]');
var src=document.querySelector('#view-grouped .card[data-slug="'+CSS.escape(slug)+'"]') || document.querySelector('.card[data-slug="'+CSS.escape(slug)+'"]');
if(src){ favgrid.appendChild(src.cloneNode(true)); }
} else if(!on && has){ has.remove(); }
if(favsec) favsec.style.display = favgrid.children.length ? '' : 'none';
@@ -55,7 +78,7 @@
setFav(slug,on);
fetch('/api/favorite',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({slug:slug,on:on})});
return; }
var vb=e.target.closest && e.target.closest('.wt-segment button');
var vb=e.target.closest && e.target.closest('.viewtoggle button');
if(vb){ var v=vb.getAttribute('data-view'); setView(v);
fetch('/api/view',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({view:v})}); }
});
@@ -68,13 +91,13 @@
if(!inp || inp.__wired) return; inp.__wired=true;
inp.addEventListener('input',function(){
var q=inp.value.trim().toLowerCase(), shown=0;
document.querySelectorAll('.wt-tile[data-search]').forEach(function(c){
document.querySelectorAll('.card[data-search]').forEach(function(c){
var hit=!q || c.getAttribute('data-search').indexOf(q)>-1;
c.classList.toggle('hidden',!hit); if(hit && c.offsetParent!==null) shown++;
});
document.querySelectorAll('.appgroup').forEach(function(s){
if(s.id==='favsec') return;
var any=s.querySelector('.wt-tile:not(.hidden)'); s.style.display=any?'':'none';
var any=s.querySelector('.card:not(.hidden)'); s.style.display=any?'':'none';
});
var nm=document.getElementById('nomatch'); if(nm) nm.style.display=shown?'none':'block';
});
@@ -82,7 +105,7 @@
fetch('/dashboard-content',{headers:{'X-Requested-With':'fetch'}})
.then(function(r){ if(!r.ok) throw 0; return r.text(); })
.then(function(html){ document.getElementById('dash').innerHTML=html; initDash(); })
.catch(function(){ document.getElementById('dash').innerHTML='<p class="wt-muted" style="margin-top:1.6rem">Kunne ikke hente tjenester — prøv at genindlæse siden.</p>'; });
.catch(function(){ document.getElementById('dash').innerHTML='<p class="muted" style="margin-top:1.6rem">Kunne ikke hente tjenester — prøv at genindlæse siden.</p>'; });
})();
</script>
{% endblock %}
+38 -19
View File
@@ -1,39 +1,58 @@
{% macro card(a) %}
<div class="wt-tile" data-slug="{{ a.slug }}" data-search="{{ (a.name ~ ' ' ~ a.desc ~ ' ' ~ a.host ~ ' ' ~ a.group)|lower }}">
<a class="wt-tile-link" href="{{ a.url }}" aria-label="{{ a.name }}"></a>
<div class="card" data-slug="{{ a.slug }}" data-search="{{ (a.name ~ ' ' ~ a.desc ~ ' ' ~ a.host ~ ' ' ~ a.group)|lower }}">
<a class="card-link" href="{{ a.url }}" aria-label="{{ a.name }}"></a>
<button type="button" class="fav{% if a.slug in favorites %} on{% endif %}" data-slug="{{ a.slug }}"
aria-pressed="{{ 'true' if a.slug in favorites else 'false' }}" title="Markér som favorit" aria-label="Favorit">★</button>
<div class="wt-tile-head">
<span class="wt-tile-ico">{% if a.icon and a.icon.startswith('http') %}<img src="{{ a.icon }}" alt="">{% else %}{{ (a.name or '?')[0]|upper }}{% endif %}</span>
<span class="wt-tile-name">{{ a.name }}</span>
<div class="head">
<span class="ico">{% if a.icon and a.icon.startswith('http') %}<img src="{{ a.icon }}" alt="">{% else %}{{ (a.name or '?')[0]|upper }}{% endif %}</span>
<span class="name">{{ a.name }}</span>
</div>
<div class="wt-tile-sub" title="{{ a.desc if a.desc else a.host }}">{{ a.desc if a.desc else a.host }}</div>
<div class="wt-tile-go">Åbn →</div>
<div class="sub" title="{{ a.desc if a.desc else a.host }}">{{ a.desc if a.desc else a.host }}</div>
<div class="go">Åbn →</div>
</div>
{% endmacro %}
{% if error %}<div class="wt-flash wt-flash--error">{{ error }}</div>{% endif %}
{% if error %}<div class="err">{{ error }}</div>{% endif %}
{% if groups %}
<div class="wt-toolbar">
<div class="wt-field" style="flex:1;min-width:12rem"><span class="wt-field-ico">⌕</span>
<input id="appfilter" class="wt-input" type="search" placeholder="Filtrér…" autocomplete="off" aria-label="Filtrér tjenester"></div>
<div class="wt-segment" role="group" aria-label="Visning">
<button type="button" data-view="grouped" class="{{ 'is-on' if view != 'alpha' }}">Grupperet</button>
<button type="button" data-view="alpha" class="{{ 'is-on' if view == 'alpha' }}">Alfabetisk</button>
<div class="toolbar">
<div class="filter"><span class="mag">⌕</span>
<input id="appfilter" type="search" placeholder="Filtrér…" autocomplete="off" aria-label="Filtrér tjenester"></div>
<div class="viewtoggle" role="group" aria-label="Visning">
<button type="button" data-view="grouped" class="{{ 'on' if view != 'alpha' }}">Grupperet</button>
<button type="button" data-view="alpha" class="{{ 'on' if view == 'alpha' }}">Alfabetisk</button>
</div>
</div>
<section id="favsec" class="appgroup"{% if not fav_apps %} style="display:none"{% endif %}>
<h2>★ Favoritter</h2>
<div class="wt-grid" id="favgrid">{% for a in fav_apps %}{{ card(a) }}{% endfor %}</div>
<div class="grid" id="favgrid">{% for a in fav_apps %}{{ card(a) }}{% endfor %}</div>
</section>
{% if mc_servers %}
<section class="appgroup" id="mcsec">
<h2>⛏️ Minecraft-servere</h2>
<div class="grid">
{% for s in mc_servers %}
<div class="card" data-search="minecraft {{ (s.name ~ ' ' ~ s.version ~ ' ' ~ s.status)|lower }}">
<a class="card-link" href="{{ disco_url }}" aria-label="{{ s.name }} i DiscoPanel"></a>
<div class="head"><span class="ico">⛏️</span><span class="name">{{ s.name }}</span></div>
<div class="sub" title="{{ s.version }}">
<span class="mcdot {{ s.css }}"></span>{{ s.status }}
{%- if s.online %} · {{ s.players }}/{{ s.max_players }} spillere{% endif %}
{%- if s.version %} · {{ s.version }}{% endif %}
</div>
<div class="go">Åbn DiscoPanel →</div>
</div>
{% endfor %}
</div>
</section>
{% endif %}
<div id="view-grouped"{% if view == 'alpha' %} hidden{% endif %}>
{% for g in groups %}
<section class="appgroup"><h2>{{ g.name }}</h2><div class="wt-grid">{% for a in g.apps %}{{ card(a) }}{% endfor %}</div></section>
<section class="appgroup"><h2>{{ g.name }}</h2><div class="grid">{% for a in g.apps %}{{ card(a) }}{% endfor %}</div></section>
{% endfor %}
</div>
<div id="view-alpha"{% if view != 'alpha' %} hidden{% endif %}>
<section class="appgroup"><h2>Alle tjenester</h2><div class="wt-grid">{% for a in apps_sorted %}{{ card(a) }}{% endfor %}</div></section>
<section class="appgroup"><h2>Alle tjenester</h2><div class="grid">{% for a in apps_sorted %}{{ card(a) }}{% endfor %}</div></section>
</div>
<p id="nomatch" class="wt-muted" style="display:none;margin-top:1rem">Ingen tjenester matcher.</p>
<p id="nomatch" class="muted" style="display:none;margin-top:1rem">Ingen tjenester matcher.</p>
{% elif not error %}
<p class="wt-muted" style="margin-top:1.6rem">Ingen apps fundet for din konto endnu.</p>
<p class="muted" style="margin-top:1.6rem">Ingen apps fundet for din konto endnu.</p>
{% endif %}