feat: adopt shared weirsoe-theme (assets.weircon.dk) — drop inline CSS, migrate to .wt-* classes + portal.css glue

This commit is contained in:
agw
2026-06-15 11:44:44 +02:00
parent 742d08c9fb
commit 8f73960fb4
15 changed files with 242 additions and 360 deletions
+74
View File
@@ -0,0 +1,74 @@
/* weircon portal — app-specific glue on top of the shared weirsoe-theme.
The theme (assets.weircon.dk/css/weirsoe-theme.css) owns the look: tokens,
chrome, cards, buttons, inputs, palette. This file is ONLY the portal's own
bits — self-hosted font, base typography, and widgets the theme doesn't have
(favourite star, skeletons, ops slots). Everything here uses theme tokens. */
/* Self-hosted Inter (pixel-exact) — drives the theme's sans token. */
@font-face {
font-family: "Inter Variable"; font-style: normal; font-weight: 100 900;
font-display: swap; src: url("/static/fonts/inter.woff2") format("woff2");
}
:root {
--wt-font-sans: "Inter Variable", ui-sans-serif, system-ui, -apple-system,
"Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; display: flex; flex-direction: column; -webkit-font-smoothing: antialiased; }
.wt-main { flex: 1; }
a { color: inherit; }
/* app typography */
h1 { font-size: 2.2rem; letter-spacing: -.025em; margin: .6rem 0 .35rem; font-weight: 800; }
h2 { font-size: .8rem; text-transform: uppercase; letter-spacing: .14em; color: var(--color-muted); margin: 2.6rem 0 1rem; font-weight: 600; }
code { background: var(--color-surface-2); padding: .12em .42em; border-radius: .35rem; font-size: .85em; font-family: var(--wt-font-mono); }
.wt-mono { font-family: var(--wt-font-mono); }
/* bare form controls opt into the theme's input look */
input:not([type=checkbox]):not([type=radio]):not([type=submit]):not([type=button]),
textarea, select {
width: 100%; background: var(--color-surface-2); border: 1px solid var(--color-border);
color: var(--color-text); border-radius: var(--wt-radius-sm); padding: .5rem .65rem; font: inherit;
}
input:focus, textarea:focus, select:focus { outline: none; border-color: var(--color-accent); }
textarea { min-height: 5rem; resize: vertical; }
label { display: block; }
/* nav identity line */
.wt-nav .who { color: var(--color-muted); font-size: .9rem; }
/* favourite star + click-overlay on a tile */
.wt-tile-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(--color-border); cursor: pointer;
font-size: 1.05rem; line-height: 1; padding: 0; transition: color .15s, transform .15s;
}
.fav:hover { color: var(--color-muted); transform: scale(1.15); }
.fav.on { color: #f5c518; }
/* skeleton placeholders */
.wt-tile.skel { opacity: .5; pointer-events: none; }
.wt-tile.skel .wt-tile-ico { background: var(--color-surface-2); }
.sk { display: inline-block; background: var(--color-surface-2); border-radius: 5px; }
/* selectable card — link + dim variants (admin → users) */
a.wt-select-card { text-decoration: none; color: inherit; }
a.wt-select-card .unav { flex: 0 0 auto; color: var(--color-accent); opacity: .7; }
.wt-select-card.dim { opacity: .55; }
/* generic show/hide hooks used by the filter JS */
[hidden] { display: none !important; }
.hidden { display: none !important; }
/* ops — random-proxy egress slots */
.slot { background: color-mix(in srgb, var(--color-surface) 70%, transparent); border: 1px solid var(--color-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(--color-muted); font-size: .85rem; }
.slot-state { font-size: .72rem; border-radius: 999px; padding: .05rem .5rem; border: 1px solid var(--color-border); color: var(--color-muted); }
.slot-state.live { color: var(--color-good); border-color: color-mix(in srgb, var(--color-good) 40%, transparent); }
.slot-state.busy { color: #f5a623; border-color: color-mix(in srgb, #f5a623 40%, transparent); }
.slot-state.down { color: var(--color-bad); border-color: color-mix(in srgb, var(--color-bad) 40%, transparent); }
.slot-next { color: #f5a623; font-size: .74rem; margin-top: .35rem; }
+6 -6
View File
@@ -2,13 +2,13 @@
{% block title %}Weircon — admin{% endblock %}
{% block body %}
<h1>Admin-panel</h1>
<p class="lead">Indstillinger for homelab'et. Tilføj nye moduler i <code>ADMIN_MODULES</code>.</p>
<div class="grid" style="margin-top:1.6rem">
<p class="wt-lead">Indstillinger for homelab'et. Tilføj nye moduler i <code>ADMIN_MODULES</code>.</p>
<div class="wt-grid" style="margin-top:1.6rem">
{% for m in modules %}
<a class="card" href="/admin/{{ m.key }}">
<div class="head"><span class="ico">{{ m.icon }}</span><span class="name">{{ m.title }}</span></div>
<div class="sub">{{ m.desc }}</div>
<div class="go">Åbn →</div>
<a class="wt-tile" href="/admin/{{ m.key }}">
<div class="wt-tile-head"><span class="wt-tile-ico">{{ m.icon }}</span><span class="wt-tile-name">{{ m.title }}</span></div>
<div class="wt-tile-sub">{{ m.desc }}</div>
<div class="wt-tile-go">Åbn →</div>
</a>
{% endfor %}
</div>
+5 -5
View File
@@ -2,10 +2,10 @@
{% block title %}Admin — app-beskrivelser{% endblock %}
{% block body %}
<h1>App-beskrivelser &amp; grupper</h1>
<p class="lead">Beskrivelsen og gruppen kommer fra hver apps felter i Authentik. Rediger her — det gemmes tilbage i Authentik og slår igennem på både portalen og Authentiks egen side.</p>
{% if saved %}<div class="flash">{{ saved }}</div>{% endif %}
<p class="wt-lead">Beskrivelsen og gruppen kommer fra hver apps felter i Authentik. Rediger her — det gemmes tilbage i Authentik og slår igennem på både portalen og Authentiks egen side.</p>
{% if saved %}<div class="wt-flash">{{ saved }}</div>{% endif %}
<form method="post" action="/admin/apps" id="appform">
<table style="margin-top:1rem">
<table class="wt-table" style="margin-top:1rem">
<thead><tr><th style="width:24%">App</th><th style="width:18%">Gruppe</th><th>Beskrivelse</th></tr></thead>
<tbody>
{% for r in rows %}
@@ -20,7 +20,7 @@
<datalist id="groups">
{% for g in rows|map(attribute='group')|reject('equalto','')|unique %}<option value="{{ g }}">{% endfor %}
</datalist>
<p style="margin-top:1.1rem"><button class="btn" type="submit" id="savebtn">Gem alle</button></p>
<p style="margin-top:1.1rem"><button class="wt-btn wt-btn--primary" type="submit" id="savebtn">Gem alle</button></p>
</form>
<script>
document.getElementById('appform').addEventListener('submit', function(){
@@ -28,4 +28,4 @@ document.getElementById('appform').addEventListener('submit', function(){
b.disabled=true; b.textContent='Der går lige et øjeblik…';
});
</script>
{% endblock %}
{% endblock %}
+3 -3
View File
@@ -2,13 +2,13 @@
{% block title %}Admin — cookies{% endblock %}
{% block body %}
<h1>Cookie-tekster</h1>
<p class="lead">Teksterne der vises i cookie-banneret og på /cookies. (Frontend kan læse dem fra <code>/api/settings</code>.)</p>
{% if saved %}<div class="flash">{{ saved }}</div>{% endif %}
<p class="wt-lead">Teksterne der vises i cookie-banneret og på /cookies. (Frontend kan læse dem fra <code>/api/settings</code>.)</p>
{% if saved %}<div class="wt-flash">{{ saved }}</div>{% endif %}
<form method="post" action="/admin/cookies">
<h2>Banner-tekst</h2>
<textarea name="cookie_banner">{{ banner }}</textarea>
<h2>Tekst på /cookies</h2>
<textarea name="cookie_page">{{ page }}</textarea>
<p style="margin-top:1rem"><button class="btn" type="submit">Gem</button></p>
<p style="margin-top:1rem"><button class="wt-btn wt-btn--primary" type="submit">Gem</button></p>
</form>
{% endblock %}
+16 -16
View File
@@ -2,45 +2,45 @@
{% block title %}Admin — {{ g.name }}{% endblock %}
{% macro ucard(u) %}
<label class="ucard" data-search="{{ (u.username ~ ' ' ~ (u.name or '') ~ ' ' ~ (u.email or ''))|lower }}">
<label class="wt-select-card" data-search="{{ (u.username ~ ' ' ~ (u.name or '') ~ ' ' ~ (u.email or ''))|lower }}">
<input type="checkbox" name="members" value="{{ u.pk }}" {% if u.pk|string in g.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 class="wt-select-card-ini">{{ (u.username or '?')[0]|upper }}</span>
<span class="wt-select-card-body">
<span class="wt-select-card-name">{{ u.name or u.username }}</span>
<span class="wt-select-card-sub" title="{{ u.username }}{% if u.email %} · {{ u.email }}{% endif %}">{{ u.username }}{% if u.email %} · {{ u.email }}{% endif %}</span>
</span>
<span class="uchk">✓</span>
<span class="wt-select-card-chk">✓</span>
</label>
{% endmacro %}
{% block body %}
<p><a href="/admin/groups" class="muted">← Grupper</a></p>
<p><a href="/admin/groups" class="wt-muted">← Grupper</a></p>
<h1>{{ g.name }}</h1>
<p class="lead">Klik på brugerne der skal være medlem. Markerede er medlemmer.</p>
<p class="wt-lead">Klik på brugerne der skal være medlem. Markerede er medlemmer.</p>
{% 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/groups/{{ g.pk }}">
<div class="filter" style="max-width:22rem">
<span class="mag">⌕</span>
<input id="ufilter" type="search" placeholder="Søg brugere…" autocomplete="off" aria-label="Søg brugere">
<div class="wt-field" style="max-width:22rem">
<span class="wt-field-ico">⌕</span>
<input id="ufilter" class="wt-input" type="search" placeholder="Søg brugere…" autocomplete="off" aria-label="Søg brugere">
</div>
<div class="ucards" id="ucards">
<div class="wt-grid wt-grid--tight" id="ucards" style="margin-top:1rem">
{% for u in people %}{{ ucard(u) }}{% endfor %}
</div>
{% if svcs %}
<details style="margin-top:1.2rem">
<summary class="muted" style="cursor:pointer">Service-konti ({{ svcs|length }})</summary>
<div class="ucards">{% for u in svcs %}{{ ucard(u) }}{% endfor %}</div>
<summary class="wt-muted" style="cursor:pointer">Service-konti ({{ svcs|length }})</summary>
<div class="wt-grid wt-grid--tight" style="margin-top:.6rem">{% for u in svcs %}{{ ucard(u) }}{% endfor %}</div>
</details>
{% endif %}
<p style="margin-top:1.3rem"><button class="btn" type="submit">Gem medlemmer</button></p>
<p style="margin-top:1.3rem"><button class="wt-btn wt-btn--primary" type="submit">Gem medlemmer</button></p>
</form>
<script>
(function(){
var inp=document.getElementById('ufilter'); if(!inp) return;
var cards=[].slice.call(document.querySelectorAll('#ucards .ucard'));
var cards=[].slice.call(document.querySelectorAll('#ucards .wt-select-card'));
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); });
+5 -5
View File
@@ -2,17 +2,17 @@
{% block title %}Admin — grupper{% endblock %}
{% block body %}
<h1>Grupper</h1>
<p class="lead">Grupper styrer hvilke services brugere har adgang til. Bind en app til en gruppe i Authentik, og alle i gruppen får adgang.</p>
{% if saved %}<div class="flash">{{ saved }}</div>{% endif %}
<p class="wt-lead">Grupper styrer hvilke services brugere har adgang til. Bind en app til en gruppe i Authentik, og alle i gruppen får adgang.</p>
{% if saved %}<div class="wt-flash">{{ saved }}</div>{% endif %}
<form method="post" action="/admin/groups" style="display:flex;gap:.6rem;align-items:end;margin:1rem 0;max-width:28rem">
<label style="flex:1">Ny gruppe<input name="name" required autocomplete="off" placeholder="fx Familie"></label>
<button class="btn" type="submit">Opret</button>
<button class="wt-btn wt-btn--primary" type="submit">Opret</button>
</form>
<table>
<table class="wt-table">
<thead><tr><th>Gruppe</th><th>Medlemmer</th><th></th></tr></thead>
<tbody>
{% for g in groups %}
<tr><td><strong>{{ g.name }}</strong></td><td class="muted">{{ g.members }}</td><td><a href="/admin/groups/{{ g.pk }}">Medlemmer →</a></td></tr>
<tr><td><strong>{{ g.name }}</strong></td><td class="wt-muted">{{ g.members }}</td><td><a href="/admin/groups/{{ g.pk }}">Medlemmer →</a></td></tr>
{% endfor %}
</tbody>
</table>
+8 -8
View File
@@ -2,24 +2,24 @@
{% block title %}Admin — IP-labels{% endblock %}
{% block body %}
<h1>IP → label &amp; land</h1>
<p class="lead">Mærk kendte IP-adresser. Nøgle = præcis IP eller præfiks (fx <code>192.168.</code>). Land = ISO-kode (fx <code>dk</code>) der overstyrer geolokation. Gemmes live til NPMplus.</p>
{% if saved %}<div class="flash">{{ saved }}</div>{% endif %}
<p class="wt-lead">Mærk kendte IP-adresser. Nøgle = præcis IP eller præfiks (fx <code>192.168.</code>). Land = ISO-kode (fx <code>dk</code>) der overstyrer geolokation. Gemmes live til NPMplus.</p>
{% if saved %}<div class="wt-flash">{{ saved }}</div>{% endif %}
<form method="post" action="/admin/ip-labels">
<table id="tbl" style="margin-top:1rem">
<table id="tbl" class="wt-table" style="margin-top:1rem">
<thead><tr><th style="width:40%">IP / præfiks</th><th>Label</th><th style="width:90px">Land</th><th style="width:40px"></th></tr></thead>
<tbody>
{% for r in rows %}
<tr><td><input name="ip" value="{{ r.ip }}"></td><td><input name="label" value="{{ r.label }}"></td><td><input name="country" value="{{ r.country }}"></td><td><button type="button" class="btn secondary" onclick="this.closest('tr').remove()">✕</button></td></tr>
<tr><td><input name="ip" value="{{ r.ip }}"></td><td><input name="label" value="{{ r.label }}"></td><td><input name="country" value="{{ r.country }}"></td><td><button type="button" class="wt-btn" onclick="this.closest('tr').remove()">✕</button></td></tr>
{% endfor %}
<tr><td><input name="ip" placeholder="fx 192.168."></td><td><input name="label" placeholder="Hjemme"></td><td><input name="country" placeholder="dk"></td><td><button type="button" class="btn secondary" onclick="this.closest('tr').remove()">✕</button></td></tr>
<tr><td><input name="ip" placeholder="fx 192.168."></td><td><input name="label" placeholder="Hjemme"></td><td><input name="country" placeholder="dk"></td><td><button type="button" class="wt-btn" onclick="this.closest('tr').remove()">✕</button></td></tr>
</tbody>
</table>
<p style="margin-top:1rem;display:flex;gap:.6rem">
<button type="button" class="btn secondary" onclick="addRow()">+ Tilføj række</button>
<button type="submit" class="btn">Gem</button>
<button type="button" class="wt-btn" onclick="addRow()">+ Tilføj række</button>
<button type="submit" class="wt-btn wt-btn--primary">Gem</button>
</p>
</form>
<script>
function addRow(){var t=document.querySelector('#tbl tbody');var tr=document.createElement('tr');tr.innerHTML='<td><input name="ip"></td><td><input name="label"></td><td><input name="country"></td><td><button type="button" class="btn secondary" onclick="this.closest(\'tr\').remove()">✕</button></td>';t.appendChild(tr);}
function addRow(){var t=document.querySelector('#tbl tbody');var tr=document.createElement('tr');tr.innerHTML='<td><input name="ip"></td><td><input name="label"></td><td><input name="country"></td><td><button type="button" class="wt-btn" onclick="this.closest(\'tr\').remove()">✕</button></td>';t.appendChild(tr);}
</script>
{% endblock %}
+13 -26
View File
@@ -2,35 +2,35 @@
{% block title %}Admin — drift{% endblock %}
{% block body %}
<h1>Drift / egress</h1>
<p class="lead">Live driftsoverblik. Genindlæser automatisk hvert 20. sekund.</p>
<p class="wt-lead">Live driftsoverblik. Genindlæser automatisk hvert 20. sekund.</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 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="muted" style="font-size:.8rem;letter-spacing:0;text-transform:none">
{% 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="err">Kunne ikke nå random-proxy: {{ rp.error }}</div>
<div class="wt-flash wt-flash--error">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 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="font-mono">{{ rot.next_egress_ip }}</span>
— <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="muted">(interval {{ rot.interval_sec }}s · sidst: slot {{ rot.last_slot }} for {{ rot.last_rotated_age_sec }}s siden)</span>
<span class="wt-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))">
<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">
@@ -38,8 +38,8 @@
<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">
<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>
@@ -48,18 +48,5 @@
{% endfor %}
</div>
{% endif %}
<style>
.font-mono { font-family: ui-monospace, "Cascadia Code", Menlo, Consolas, monospace; }
.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:var(--ok, #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; }
</style>
<script>setTimeout(function(){ location.reload(); }, 20000);</script>
{% endblock %}
+6 -6
View File
@@ -2,14 +2,14 @@
{% block title %}Admin — statistik{% endblock %}
{% block body %}
<h1>Statistik</h1>
<p class="lead">Hurtigt overblik fra Matomo (sidste 7 dage).</p>
<p class="wt-lead">Hurtigt overblik fra Matomo (sidste 7 dage).</p>
{% if stats %}
<div class="grid" style="margin-top:1.5rem">
<div class="stat"><div class="n">{{ stats.visits_7d }}</div><div class="muted">besøg (7 dage)</div></div>
<div class="stat"><div class="n">{{ stats.actions_7d }}</div><div class="muted">handlinger (7 dage)</div></div>
<div class="wt-grid" style="margin-top:1.5rem">
<div class="wt-stat"><div class="wt-stat-n">{{ stats.visits_7d }}</div><div class="wt-muted">besøg (7 dage)</div></div>
<div class="wt-stat"><div class="wt-stat-n">{{ stats.actions_7d }}</div><div class="wt-muted">handlinger (7 dage)</div></div>
</div>
<p style="margin-top:1.5rem"><a class="btn secondary" href="{{ matomo }}">Åbn Matomo →</a></p>
<p style="margin-top:1.5rem"><a class="wt-btn" href="{{ matomo }}">Åbn Matomo →</a></p>
{% else %}
<div class="err">Kunne ikke hente data fra Matomo (mangler token eller forbindelse).</div>
<div class="wt-flash wt-flash--error">Kunne ikke hente data fra Matomo (mangler token eller forbindelse).</div>
{% endif %}
{% endblock %}
+3 -3
View File
@@ -1,9 +1,9 @@
{% extends "base.html" %}
{% block title %}Admin — {{ u.username }}{% endblock %}
{% block body %}
<p><a href="/admin/users" class="muted">← Brugere</a></p>
<p><a href="/admin/users" class="wt-muted">← Brugere</a></p>
<h1>{{ u.username }}</h1>
<p class="lead">{{ u.name }}{% if u.email %} · {{ u.email }}{% endif %}</p>
<p class="wt-lead">{{ u.name }}{% if u.email %} · {{ u.email }}{% endif %}</p>
<form method="post" action="/admin/users/{{ u.pk }}">
<label style="display:flex;gap:.5rem;align-items:center;margin:1rem 0"><input type="checkbox" name="is_active" {% if u.active %}checked{% endif %} style="width:auto"> Aktiv konto</label>
<h2>Nulstil adgangskode</h2>
@@ -14,6 +14,6 @@
<label style="display:flex;gap:.4rem;align-items:center;font-size:.9rem"><input type="checkbox" name="groups" value="{{ g.pk }}" {% if g.pk in u.groups %}checked{% endif %} style="width:auto"> {{ g.name }}</label>
{% endfor %}
</div>
<p style="margin-top:1.2rem"><button class="btn" type="submit">Gem</button></p>
<p style="margin-top:1.2rem"><button class="wt-btn wt-btn--primary" type="submit">Gem</button></p>
</form>
{% endblock %}
+20 -29
View File
@@ -2,68 +2,59 @@
{% block title %}Admin — brugere{% endblock %}
{% block body %}
<h1>Brugere</h1>
<p class="lead">Opret og administrér brugere. Adgang til services styres via <strong class="text-text">grupper</strong>.</p>
{% if saved %}<div class="flash">{{ saved }}</div>{% endif %}
<p class="wt-lead">Opret og administrér brugere. Adgang til services styres via <strong>grupper</strong>.</p>
{% if saved %}<div class="wt-flash">{{ saved }}</div>{% endif %}
<details style="margin:1rem 0">
<summary class="btn" style="display:inline-block">+ Opret ny bruger</summary>
<form method="post" action="/admin/users" class="stat" style="display:block;margin-top:.8rem">
<summary class="wt-btn wt-btn--primary" style="display:inline-block">+ Opret ny bruger</summary>
<form method="post" action="/admin/users" class="wt-stat" style="display:block;margin-top:.8rem">
<div style="display:grid;gap:.7rem;grid-template-columns:repeat(auto-fit,minmax(12rem,1fr))">
<label>Brugernavn<input name="username" required autocomplete="off"></label>
<label>Navn<input name="name" autocomplete="off"></label>
<label>Email<input name="email" type="email" autocomplete="off"></label>
<label>Adgangskode<input name="password" type="text" autocomplete="new-password" placeholder="(valgfri)"></label>
</div>
<p class="muted" style="margin:.9rem 0 .4rem;font-size:.85rem">Adgang (grupper) — sæt flueben for de services brugeren skal kunne tilgå:</p>
<p class="wt-muted" style="margin:.9rem 0 .4rem;font-size:.85rem">Adgang (grupper) — sæt flueben for de services brugeren skal kunne tilgå:</p>
<div style="display:grid;gap:.3rem .9rem;grid-template-columns:repeat(auto-fill,minmax(11rem,1fr))">
{% for grp in groups %}
<label style="display:flex;gap:.4rem;align-items:center;font-size:.9rem"><input type="checkbox" name="groups" value="{{ grp.pk }}" style="width:auto"> {{ grp.name }}</label>
{% endfor %}
</div>
<p style="margin-top:1rem"><button class="btn" type="submit">Opret bruger</button></p>
<p style="margin-top:1rem"><button class="wt-btn wt-btn--primary" type="submit">Opret bruger</button></p>
</form>
</details>
<div class="toolbar">
<div class="filter" style="flex:1;min-width:12rem"><span class="mag">⌕</span>
<input id="ufilter" type="search" placeholder="Søg brugere…" autocomplete="off"></div>
<div class="viewtoggle" id="typetoggle" role="group" aria-label="Brugertype">
<button type="button" data-type="person" class="on">Personer</button>
<div class="wt-toolbar">
<div class="wt-field" style="flex:1;min-width:12rem"><span class="wt-field-ico">⌕</span>
<input id="ufilter" class="wt-input" type="search" placeholder="Søg brugere…" autocomplete="off"></div>
<div class="wt-segment" id="typetoggle" role="group" aria-label="Brugertype">
<button type="button" data-type="person" class="is-on">Personer</button>
<button type="button" data-type="service">Service-konti</button>
<button type="button" data-type="all">Alle</button>
</div>
</div>
<div class="ucards" id="ucards" style="margin-top:1rem">
<div class="wt-grid wt-grid--tight" id="ucards" style="margin-top:1rem">
{% for u in users %}
{% set svc = u.type in ['service_account','internal_service_account'] %}
<a class="ucard nav{% if not u.active %} dim{% endif %}" href="/admin/users/{{ u.pk }}"
<a class="wt-select-card{% if not u.active %} dim{% endif %}" href="/admin/users/{{ u.pk }}"
data-type="{{ 'service' if svc else 'person' }}"
data-search="{{ (u.username ~ ' ' ~ (u.name or '') ~ ' ' ~ (u.email or ''))|lower }}">
<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.group_names %} · {{ u.group_names|length }} grupper{% endif %}{% if not u.active %} · inaktiv{% endif %}</span>
<span class="wt-select-card-ini">{{ (u.username or '?')[0]|upper }}</span>
<span class="wt-select-card-body">
<span class="wt-select-card-name">{{ u.name or u.username }}</span>
<span class="wt-select-card-sub" title="{{ u.username }}{% if u.email %} · {{ u.email }}{% endif %}">{{ u.username }}{% if u.group_names %} · {{ u.group_names|length }} grupper{% endif %}{% if not u.active %} · inaktiv{% endif %}</span>
</span>
<span class="unav">→</span>
</a>
{% endfor %}
</div>
<p id="unone" class="muted" style="display:none;margin-top:1rem">Ingen brugere matcher.</p>
<p id="unone" class="wt-muted" style="display:none;margin-top:1rem">Ingen brugere matcher.</p>
<style>
.toolbar { display:flex; gap:1rem; align-items:center; flex-wrap:wrap; margin:1.4rem 0 .2rem; }
.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:.85rem; }
.viewtoggle button.on { background:var(--surface2); color:var(--text); }
.ucard.nav { text-decoration:none; color:inherit; }
.ucard.nav .unav { flex:0 0 auto; color:var(--accent); opacity:.7; }
.ucard.dim { opacity:.55; }
</style>
<script>
(function(){
var inp=document.getElementById('ufilter'), tt=document.getElementById('typetoggle');
var cards=[].slice.call(document.querySelectorAll('#ucards .ucard'));
var cards=[].slice.call(document.querySelectorAll('#ucards .wt-select-card'));
var none=document.getElementById('unone'), type='person';
function apply(){
var q=(inp.value||'').trim().toLowerCase(), shown=0;
@@ -77,7 +68,7 @@
inp.addEventListener('input',apply);
tt.addEventListener('click',function(e){var b=e.target.closest('button[data-type]'); if(!b)return;
type=b.getAttribute('data-type');
tt.querySelectorAll('button').forEach(function(x){x.classList.toggle('on',x===b);}); apply();});
tt.querySelectorAll('button').forEach(function(x){x.classList.toggle('is-on',x===b);}); apply();});
apply();
})();
</script>
+45 -197
View File
@@ -7,205 +7,53 @@
<meta name="theme-color" content="#f6f8fb" media="(prefers-color-scheme: light)" />
<link rel="icon" href="/static/icons/icon.svg" type="image/svg+xml" />
<title>{% block title %}Weircon{% endblock %}</title>
<script>
(function () {
var t = localStorage.getItem('theme') ||
(window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
document.documentElement.dataset.theme = t;
document.addEventListener('click', function (e) {
var b = e.target.closest('[data-theme-toggle]');
if (!b) return;
var next = document.documentElement.dataset.theme === 'light' ? 'dark' : 'light';
document.documentElement.dataset.theme = next;
localStorage.setItem('theme', next);
});
})();
</script>
<style>
@font-face {
font-family: "Inter Variable"; font-style: normal; font-weight: 100 900;
font-display: swap; src: url("/static/fonts/inter.woff2") format("woff2");
}
:root, html[data-theme="dark"] {
--bg:#0b0f17; --surface:#121a27; --surface2:#182232; --border:#243044;
--text:#e6e9ef; --muted:#97a3b6; --accent:#38bdf8; --accent2:#818cf8;
}
html[data-theme="light"] {
--bg:#f6f8fb; --surface:#ffffff; --surface2:#eef2f8; --border:#dde4ee;
--text:#0f1729; --muted:#54617a; --accent:#0284c7; --accent2:#4f46e5;
}
* { box-sizing:border-box; }
html { scroll-behavior:smooth; color-scheme:dark; }
html[data-theme="light"] { color-scheme:light; }
body {
margin:0; min-height:100vh; display:flex; flex-direction:column;
background:var(--bg); color:var(--text);
font:16px/1.7 "Inter Variable", ui-sans-serif, system-ui, -apple-system, sans-serif;
-webkit-font-smoothing:antialiased;
}
body, a, span, div, p, h1, h2, .card, input, button, nav { transition: background-color .35s ease, border-color .35s ease, color .35s ease; }
body::before {
content:""; position:fixed; inset:0; z-index:-1; pointer-events:none;
background:
radial-gradient(60rem 30rem at 70% -10%, color-mix(in srgb, var(--accent) 12%, transparent), transparent 70%),
radial-gradient(50rem 30rem at 10% 0%, color-mix(in srgb, var(--accent2) 10%, transparent), transparent 70%);
}
::selection { background: color-mix(in srgb, var(--accent) 35%, transparent); }
a { color:inherit; }
.wrap { width:100%; max-width:960px; margin:0 auto; padding:2.5rem 1.25rem 4rem; flex:1; }
/* nav */
nav { display:flex; align-items:center; gap:1.1rem; width:100%; max-width:960px; margin:0 auto; padding:1.1rem 1.25rem; }
nav .brand { font-weight:800; font-size:1.15rem; letter-spacing:-.02em; }
nav .brand .dot { color:var(--accent); }
nav .links { display:flex; gap:1.1rem; }
nav a { text-decoration:none; color:var(--muted); font-size:.95rem; font-weight:500; }
nav a:hover { color:var(--text); }
nav .spacer { flex:1; }
nav .who { color:var(--muted); font-size:.9rem; }
/* typografi */
h1 { font-size:2.2rem; letter-spacing:-.025em; margin:.6rem 0 .35rem; font-weight:800; }
h2 { font-size:.8rem; text-transform:uppercase; letter-spacing:.14em; color:var(--muted); margin:2.6rem 0 1rem; font-weight:600; }
.lead { color:var(--muted); font-size:1.05rem; max-width:46rem; }
/* filter */
.filter { margin:1.4rem 0 .2rem; position:relative; max-width:22rem; }
.filter input { padding-left:2.2rem; }
.filter .mag { position:absolute; left:.7rem; top:50%; transform:translateY(-50%); color:var(--muted); }
/* card-grid med ENS størrelser */
.grid { display:grid; gap:.9rem; grid-template-columns:repeat(auto-fill, minmax(220px, 1fr)); margin-top:1rem; }
.card {
display:flex; flex-direction:column; min-height:7.25rem; height:100%;
background:color-mix(in srgb, var(--surface) 70%, transparent);
border:1px solid var(--border); border-radius:1rem; padding:1.1rem 1.15rem;
text-decoration:none; color:inherit;
transition:border-color .18s ease, transform .18s ease, background .18s ease;
}
a.card:hover { border-color:var(--accent); transform:translateY(-3px);
background:color-mix(in srgb, var(--surface) 92%, transparent); }
.card.hidden { display:none; }
.card .head { display:flex; align-items:center; gap:.65rem; }
.card .ico {
flex:0 0 auto; width:2.1rem; height:2.1rem; border-radius:.6rem;
display:grid; place-items:center; font-weight:700; font-size:1rem;
background:color-mix(in srgb, var(--accent) 16%, transparent);
color:var(--accent); overflow:hidden;
}
.card .ico img { width:100%; height:100%; object-fit:contain; }
.card .name { font-weight:600; letter-spacing:-.01em; line-height:1.25; }
.card .sub { color:var(--muted); font-size:.86rem; margin-top:.6rem; flex:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.card .go { color:var(--accent); font-size:.8rem; margin-top:.7rem; opacity:.85; }
/* knapper / forms */
.btn { display:inline-block; background:var(--accent); color:#06101f; font-weight:600;
text-decoration:none; padding:.6rem 1.15rem; border-radius:.7rem; border:0; cursor:pointer; font:inherit; font-size:.95rem; }
.btn:hover { filter:brightness(1.08); }
.btn.secondary { background:var(--surface2); color:var(--text); border:1px solid var(--border); }
table { width:100%; border-collapse:collapse; }
th, td { border:1px solid var(--border); padding:.45rem .55rem; text-align:left; }
th { color:var(--muted); font-weight:600; font-size:.8rem; text-transform:uppercase; letter-spacing:.06em; }
input, textarea { width:100%; background:var(--surface2); border:1px solid var(--border); color:var(--text);
border-radius:.55rem; padding:.5rem .65rem; font:inherit; }
input:focus, textarea:focus { outline:none; border-color:var(--accent); }
textarea { min-height:5rem; resize:vertical; }
.flash { background:color-mix(in srgb, var(--accent) 12%, transparent); border:1px solid var(--accent);
color:var(--accent); border-radius:.7rem; padding:.65rem .95rem; margin:1rem 0; font-size:.9rem; }
.err { background:color-mix(in srgb, #f5a623 14%, transparent); border:1px solid #f5a623;
color:#f5a623; border-radius:.7rem; padding:.65rem .95rem; margin:1rem 0; font-size:.9rem; }
.muted { color:var(--muted); }
code { background:var(--surface2); padding:.12em .42em; border-radius:.35rem; font-size:.85em; }
.stat { background:color-mix(in srgb, var(--surface) 70%, transparent); border:1px solid var(--border); border-radius:1rem; padding:1.3rem; }
.stat .n { font-size:2.1rem; font-weight:800; letter-spacing:-.02em; }
/* bruger-kort (medlemskab/tildeling) — fixed bredde + afkortning, intet overlap */
.ucards { display:grid; gap:.55rem; grid-template-columns:repeat(auto-fill,minmax(15rem,1fr)); margin-top:.6rem; }
.ucard { position:relative; display:flex; align-items:center; gap:.6rem; padding:.55rem .7rem; border:1px solid var(--border);
border-radius:.8rem; background:color-mix(in srgb,var(--surface) 70%,transparent); cursor:pointer; min-width:0;
transition:border-color .15s, background .15s; }
.ucard:hover { border-color:var(--accent); }
.ucard input { position:absolute; opacity:0; pointer-events:none; }
.ucard:has(input:checked) { border-color:var(--accent); background:color-mix(in srgb,var(--accent) 12%,transparent); }
.ucard .uini { flex:0 0 auto; width:1.9rem; height:1.9rem; border-radius:.5rem; display:grid; place-items:center;
font-weight:700; font-size:.85rem; background:color-mix(in srgb,var(--accent) 16%,transparent); color:var(--accent); }
.ucard .ubody { min-width:0; flex:1; }
.ucard .uname { font-weight:600; font-size:.9rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.ucard .usub { color:var(--muted); font-size:.78rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.ucard .uchk { flex:0 0 auto; width:1.25rem; height:1.25rem; border:1.5px solid var(--border); border-radius:.35rem;
display:grid; place-items:center; color:transparent; font-size:.8rem; font-weight:700; }
.ucard:has(input:checked) .uchk { background:var(--accent); border-color:var(--accent); color:#06101f; }
.ucard.hidden { display:none; }
/* footer */
.foot { width:100%; max-width:960px; margin:0 auto; padding:1.6rem 1.25rem 2.5rem; color:var(--muted); font-size:.85rem; border-top:1px solid var(--border); }
.foot a { color:var(--accent); text-decoration:none; }
/* tema-toggle (sol/måne) — ført videre fra asger.weirsøe.dk */
.theme-toggle { display:inline-grid; place-items:center; width:2.25rem; height:2.25rem; border-radius:.75rem;
color:var(--muted); background:transparent; cursor:pointer; border:1px solid transparent; }
.theme-toggle:hover { color:var(--text); background:var(--surface2); }
.theme-toggle:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
.sun-and-moon > :is(.moon, .sun, .sun-beams) { transform-origin:center; }
.sun-and-moon > :is(.moon, .sun) { fill:currentColor; }
.sun-and-moon > .sun-beams { stroke:currentColor; stroke-width:2px; }
.sun-and-moon > .sun { transition:transform .5s cubic-bezier(.5,1.25,.75,1.25); }
.sun-and-moon > .sun-beams { transition:transform .5s cubic-bezier(.5,1.5,.75,1.25), opacity .5s cubic-bezier(.25,0,.3,1); }
.sun-and-moon .moon > circle { transition:transform .25s cubic-bezier(0,0,0,1); }
@supports (cx:1) { .sun-and-moon .moon > circle { transition:cx .25s cubic-bezier(0,0,0,1); } }
[data-theme="dark"] .sun-and-moon > .sun { transform:scale(1.75); transition-timing-function:cubic-bezier(.25,0,.3,1); transition-duration:.25s; }
[data-theme="dark"] .sun-and-moon > .sun-beams { opacity:0; transform:rotateZ(-25deg); transition-duration:.15s; }
[data-theme="dark"] .sun-and-moon > .moon > circle { transform:translateX(-7px); transition-delay:.25s; transition-duration:.5s; }
@supports (cx:1) { [data-theme="dark"] .sun-and-moon > .moon > circle { cx:17; transform:translateX(0); } }
@media (prefers-reduced-motion: reduce) { .sun-and-moon *, body, .card { transition:none !important; } }
</style>
<!-- no-flash: set theme before any stylesheet paints (key 'theme', shared with the theme JS) -->
<script>(()=>{try{var t=localStorage.getItem('theme')||(matchMedia('(prefers-color-scheme: light)').matches?'light':'dark');document.documentElement.dataset.theme=t}catch(e){document.documentElement.dataset.theme='dark'}})()</script>
<!-- shared design system (SSOT: agw/Weircon-wind) hosted on assets.weircon.dk -->
<link rel="stylesheet" href="https://assets.weircon.dk/css/weirsoe-theme.css" />
<link rel="stylesheet" href="/static/portal.css" />
<script defer src="https://assets.weircon.dk/js/weirsoe-theme.js"></script>
</head>
<body>
<nav>
<span class="brand">weircon<span class="dot">.</span></span>
<div class="links">
<a href="/">Forside</a>
{% if is_admin %}<a href="/admin">Admin</a>{% endif %}
</div>
<span class="spacer"></span>
{% if user %}<span class="who">{{ user.name }}</span> <a href="/auth/logout">Log ud</a>{% endif %}
<button type="button" data-theme-toggle class="theme-toggle" title="Skift mellem lyst og mørkt tema" aria-label="Skift tema">
<svg class="sun-and-moon" aria-hidden="true" width="22" height="22" viewBox="0 0 24 24">
<mask class="moon" id="moon-mask"><rect x="0" y="0" width="100%" height="100%" fill="white" /><circle cx="24" cy="10" r="6" fill="black" /></mask>
<circle class="sun" cx="12" cy="12" r="6" mask="url(#moon-mask)" fill="currentColor" />
<g class="sun-beams" stroke="currentColor">
<line x1="12" y1="1" x2="12" y2="3" /><line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" /><line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" /><line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" /><line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</g>
</svg>
</button>
</nav>
<div class="wrap">
<header class="wt-header">
<nav class="wt-nav">
<a class="wt-brand" href="/">weircon<span class="wt-brand-dot">.</span></a>
<div class="wt-nav-links">
<a class="wt-nav-link" href="/">Forside</a>
{% if is_admin %}<a class="wt-nav-link" href="/admin">Admin</a>{% endif %}
{% if user %}<span class="who">{{ user.name }}</span> <a class="wt-nav-link" href="/auth/logout">Log ud</a>{% endif %}
<button type="button" data-theme-toggle class="theme-toggle" title="Skift mellem lyst og mørkt tema" aria-label="Skift tema">
<svg class="sun-and-moon" aria-hidden="true" width="22" height="22" viewBox="0 0 24 24">
<mask class="moon" id="moon-mask"><rect x="0" y="0" width="100%" height="100%" fill="white" /><circle cx="24" cy="10" r="6" fill="black" /></mask>
<circle class="sun" cx="12" cy="12" r="6" mask="url(#moon-mask)" fill="currentColor" />
<g class="sun-beams" stroke="currentColor">
<line x1="12" y1="1" x2="12" y2="3" /><line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" /><line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" /><line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" /><line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</g>
</svg>
</button>
</div>
</nav>
</header>
<main class="wt-main">
{% block body %}{% endblock %}
</div>
<footer class="foot">
Driftet af Asger Weirsøe · self-hosted på Proxmox · <a href="mailto:asger@weirsoe.dk">asger@weirsoe.dk</a>
</main>
<footer class="wt-footer">
<div class="wt-footer-inner">
Driftet af Asger Weirsøe · self-hosted på Proxmox · <a href="mailto:asger@weirsoe.dk">asger@weirsoe.dk</a>
</div>
</footer>
<!-- ⌘K / Ctrl+K kommandopalette — ført videre fra asger.weirsøe.dk, tilpasset portalens apps -->
<div id="palette" class="palette-overlay" hidden>
<div class="palette-box" role="dialog" aria-modal="true" aria-label="Kommandopalette">
<input id="palette-input" class="palette-input" type="text" placeholder="Søg tjenester, sider eller kommandoer…" autocomplete="off" spellcheck="false" />
<ul id="palette-list" class="palette-list" role="listbox"></ul>
<div class="palette-hint">↑↓ navigér · ↵ vælg · esc luk · ⌘/Ctrl K åbn</div>
<!-- ⌘K / Ctrl+K kommandopalette -->
<div id="palette" class="wt-palette" hidden>
<div class="wt-palette-box" role="dialog" aria-modal="true" aria-label="Kommandopalette">
<input id="palette-input" class="wt-palette-input" type="text" placeholder="Søg tjenester, sider eller kommandoer…" autocomplete="off" spellcheck="false" />
<ul id="palette-list" class="wt-palette-list" role="listbox"></ul>
<div class="wt-palette-hint">↑↓ navigér · ↵ vælg · esc luk · ⌘/Ctrl K åbn</div>
</div>
</div>
<style>
.palette-overlay { position:fixed; inset:0; z-index:100;
background:color-mix(in srgb, var(--bg) 60%, transparent); backdrop-filter:blur(4px);
display:flex; align-items:flex-start; justify-content:center; padding-top:15vh; }
.palette-overlay[hidden] { display:none; }
.palette-box { width:min(36rem, calc(100vw - 2rem)); border:1px solid var(--border); border-radius:1rem;
background:var(--surface); box-shadow:0 24px 64px rgb(0 0 0 / .45); overflow:hidden; }
.palette-input { width:100%; padding:.9rem 1.1rem; font-size:1rem; background:transparent; color:var(--text);
border:0; outline:none; border-bottom:1px solid var(--border); }
.palette-list { max-height:50vh; overflow-y:auto; padding:.35rem; margin:0; list-style:none; }
.palette-item { display:flex; justify-content:space-between; align-items:center; gap:1rem;
padding:.55rem .8rem; border-radius:.6rem; cursor:pointer; font-size:.925rem; }
.palette-item.is-selected, .palette-item:hover:not(.palette-empty) { background:var(--surface2); }
.palette-empty { color:var(--muted); cursor:default; }
.palette-badge { font-size:.7rem; color:var(--muted); border:1px solid var(--border); border-radius:999px; padding:.05rem .5rem; }
.palette-hint { padding:.5rem 1.1rem; font-size:.72rem; color:var(--muted); border-top:1px solid var(--border); }
</style>
<script>
(function () {
if (window.__palette) return; window.__palette = true;
@@ -240,11 +88,11 @@
sel = 0;
el('palette-list').innerHTML = items.map(function(it, i){
var badge = it.type === 'app' ? 'app' : it.type === 'action' ? 'kommando' : it.ext ? 'link' : 'side';
return '<li class="palette-item' + (i === sel ? ' is-selected' : '') + '" data-i="' + i + '" role="option">' +
'<span>' + String(it.title).replace(/</g, '&lt;') + '</span><span class="palette-badge">' + badge + '</span></li>';
}).join('') || '<li class="palette-item palette-empty">Ingen resultater</li>';
return '<li class="wt-palette-item' + (i === sel ? ' is-selected' : '') + '" data-i="' + i + '" role="option">' +
'<span>' + String(it.title).replace(/</g, '&lt;') + '</span><span class="wt-palette-badge">' + badge + '</span></li>';
}).join('') || '<li class="wt-palette-item wt-palette-empty">Ingen resultater</li>';
}
function paint(){ var ls = el('palette-list').querySelectorAll('.palette-item');
function paint(){ var ls = el('palette-list').querySelectorAll('.wt-palette-item');
for (var i = 0; i < ls.length; i++) ls[i].classList.toggle('is-selected', i === sel); }
function choose(i){ var it = items[i]; if (!it) return; close();
if (it.run) it.run(); else if (it.ext) window.open(it.url, '_blank', 'noopener'); else window.location.href = it.url; }
@@ -261,7 +109,7 @@
document.addEventListener('input', function(e){ if (e.target && e.target.id === 'palette-input') refresh(); });
document.addEventListener('click', function(e){
if (e.target === el('palette')) close();
var li = e.target.closest && e.target.closest('.palette-item[data-i]');
var li = e.target.closest && e.target.closest('.wt-palette-item[data-i]');
if (li) choose(+li.dataset.i);
});
})();
+16 -34
View File
@@ -2,13 +2,13 @@
{% block title %}Weircon — forside{% endblock %}
{% block body %}
<h1>Hej, {{ user.name }}</h1>
<p class="lead">Tjenester du har adgang til.</p>
{% if saved %}<div class="flash">{{ saved }}</div>{% endif %}
<p class="wt-lead">Tjenester du har adgang til.</p>
{% if saved %}<div class="wt-flash">{{ saved }}</div>{% endif %}
<div id="dash">
<div class="grid" style="margin-top:1.6rem" aria-hidden="true">
<div class="wt-grid" style="margin-top:1.6rem" aria-hidden="true">
{% for i in range(8) %}
<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>
<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>
{% endfor %}
</div>
</div>
@@ -16,41 +16,23 @@
{% if is_admin %}
<section class="appgroup">
<h2>Administration</h2>
<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>
<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>
</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; }
.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('.viewtoggle button').forEach(function(b){ b.classList.toggle('on', b.getAttribute('data-view')===v); });
document.querySelectorAll('.wt-segment button').forEach(function(b){ b.classList.toggle('is-on', b.getAttribute('data-view')===v); });
}
function setFav(slug,on){
document.querySelectorAll('.fav[data-slug="'+CSS.escape(slug)+'"]').forEach(function(b){
@@ -58,9 +40,9 @@
});
var favgrid=document.getElementById('favgrid'), favsec=document.getElementById('favsec');
if(!favgrid) return;
var has=favgrid.querySelector('.card[data-slug="'+CSS.escape(slug)+'"]');
var has=favgrid.querySelector('.wt-tile[data-slug="'+CSS.escape(slug)+'"]');
if(on && !has){
var src=document.querySelector('#view-grouped .card[data-slug="'+CSS.escape(slug)+'"]') || document.querySelector('.card[data-slug="'+CSS.escape(slug)+'"]');
var src=document.querySelector('#view-grouped .wt-tile[data-slug="'+CSS.escape(slug)+'"]') || document.querySelector('.wt-tile[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';
@@ -73,7 +55,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('.viewtoggle button');
var vb=e.target.closest && e.target.closest('.wt-segment 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})}); }
});
@@ -86,13 +68,13 @@
if(!inp || inp.__wired) return; inp.__wired=true;
inp.addEventListener('input',function(){
var q=inp.value.trim().toLowerCase(), shown=0;
document.querySelectorAll('.card[data-search]').forEach(function(c){
document.querySelectorAll('.wt-tile[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('.card:not(.hidden)'); s.style.display=any?'':'none';
var any=s.querySelector('.wt-tile:not(.hidden)'); s.style.display=any?'':'none';
});
var nm=document.getElementById('nomatch'); if(nm) nm.style.display=shown?'none':'block';
});
@@ -100,7 +82,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="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="wt-muted" style="margin-top:1.6rem">Kunne ikke hente tjenester — prøv at genindlæse siden.</p>'; });
})();
</script>
{% endblock %}
+19 -19
View File
@@ -1,39 +1,39 @@
{% macro card(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>
<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>
<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="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 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>
<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 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>
{% endmacro %}
{% if error %}<div class="err">{{ error }}</div>{% endif %}
{% if error %}<div class="wt-flash wt-flash--error">{{ error }}</div>{% endif %}
{% if groups %}
<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 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>
</div>
<section id="favsec" class="appgroup"{% if not fav_apps %} style="display:none"{% endif %}>
<h2>★ Favoritter</h2>
<div class="grid" id="favgrid">{% for a in fav_apps %}{{ card(a) }}{% endfor %}</div>
<div class="wt-grid" id="favgrid">{% for a in fav_apps %}{{ card(a) }}{% endfor %}</div>
</section>
<div id="view-grouped"{% if view == 'alpha' %} hidden{% endif %}>
{% for g in groups %}
<section class="appgroup"><h2>{{ g.name }}</h2><div class="grid">{% for a in g.apps %}{{ card(a) }}{% endfor %}</div></section>
<section class="appgroup"><h2>{{ g.name }}</h2><div class="wt-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="grid">{% for a in apps_sorted %}{{ card(a) }}{% endfor %}</div></section>
<section class="appgroup"><h2>Alle tjenester</h2><div class="wt-grid">{% for a in apps_sorted %}{{ card(a) }}{% endfor %}</div></section>
</div>
<p id="nomatch" class="muted" style="display:none;margin-top:1rem">Ingen tjenester matcher.</p>
<p id="nomatch" class="wt-muted" style="display:none;margin-top:1rem">Ingen tjenester matcher.</p>
{% elif not error %}
<p class="muted" style="margin-top:1.6rem">Ingen apps fundet for din konto endnu.</p>
<p class="wt-muted" style="margin-top:1.6rem">Ingen apps fundet for din konto endnu.</p>
{% endif %}
+3 -3
View File
@@ -2,8 +2,8 @@
{% block title %}Weircon — log ind{% endblock %}
{% block body %}
<div style="max-width:32rem;margin:10vh auto;text-align:center">
<h1 style="font-size:3rem">weircon<span style="color:var(--accent)">.</span></h1>
<p class="lead" style="margin:1rem auto 0">Selv-hostet homelab. Log ind med din konto for at se de tjenester du har adgang til.</p>
<p style="margin-top:2.2rem"><a class="btn" href="/auth/login">Log ind med Authentik →</a></p>
<h1 style="font-size:3rem">weircon<span style="color:var(--color-accent)">.</span></h1>
<p class="wt-lead" style="margin:1rem auto 0">Selv-hostet homelab. Log ind med din konto for at se de tjenester du har adgang til.</p>
<p style="margin-top:2.2rem"><a class="wt-btn wt-btn--primary" href="/auth/login">Log ind med Authentik →</a></p>
</div>
{% endblock %}