2026-08-28 11:22:32 +02:00
|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
|
<!--
|
|
|
|
|
|
═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
De statuspagina van Evolu Relay.
|
|
|
|
|
|
|
|
|
|
|
|
Zichtbare tekst is Engels: dit is een publieke app store en dit is wat een
|
|
|
|
|
|
bezoeker ziet. Commentaar is Nederlands.
|
|
|
|
|
|
|
2026-09-08 16:36:30 +02:00
|
|
|
|
Dit bestand zit in de image (tools/evolu-relay/Dockerfile) als
|
|
|
|
|
|
/usr/share/nginx/html/index.html. Tot 0.5.6 was het een *.template in de
|
|
|
|
|
|
app-map die umbreld bij het starten door envsubst haalde, en daarom plakt
|
|
|
|
|
|
alles hieronder strings met een plus aan elkaar in plaats van met een
|
|
|
|
|
|
template-literal. Die beperking is weg, maar de stijl is gebleven: één pagina
|
|
|
|
|
|
in twee stijlen is erger dan één stijl die niet meer nodig is. De versie in de
|
|
|
|
|
|
kop komt uit api/status, waar umbreld hem vroeger rechtstreeks invulde.
|
2026-08-28 11:22:32 +02:00
|
|
|
|
|
2026-08-30 12:26:56 +02:00
|
|
|
|
De maatvoering komt uit Electrum Gate en niet uit een eigen ontwerp: dezelfde
|
|
|
|
|
|
breedte, hetzelfde raster, dezelfde kop met een icoon van 64 pixels, hetzelfde
|
|
|
|
|
|
menu met drie punten, en het versienummer achter de tagline. Dat is op verzoek
|
|
|
|
|
|
van de gebruiker (30-08-2026) en het is de reden dat er hier meer CSS staat dan
|
|
|
|
|
|
de pagina op het eerste gezicht nodig heeft: twee apps naast elkaar op hetzelfde
|
|
|
|
|
|
dashboard horen niet twee ontwerpen te zijn.
|
|
|
|
|
|
|
2026-08-28 11:22:32 +02:00
|
|
|
|
Wat er bewust NIET uit overgenomen is: de verwijzing naar Google Fonts. Een app
|
|
|
|
|
|
op een Umbrel hoort niet te wachten op een lettertype van buiten.
|
2026-08-30 12:26:56 +02:00
|
|
|
|
|
|
|
|
|
|
Drie keuzes die uit diezelfde ronde komen en die je bij het wijzigen moet
|
|
|
|
|
|
kennen, want ze zijn makkelijk terug te draaien met de beste bedoelingen:
|
|
|
|
|
|
|
|
|
|
|
|
1. er staan geen uitleggende regels meer in de kaders. De koppen zeggen wat er
|
|
|
|
|
|
staat; wat er verder te weten is, staat in de dialoog "About this app";
|
|
|
|
|
|
2. knoppen zijn onzichtbaar tot je over de regel of het kader gaat. Met opacity
|
|
|
|
|
|
en niet met display:none, zodat ze met de tab-toets bereikbaar blijven;
|
|
|
|
|
|
3. er is geen voetregel. Het versienummer stond daar en staat nu achter de
|
|
|
|
|
|
tagline, waar je het zoekt als je je afvraagt of een wijziging wel uitgerold
|
|
|
|
|
|
is.
|
2026-08-28 11:22:32 +02:00
|
|
|
|
═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
-->
|
2026-09-04 09:55:11 +02:00
|
|
|
|
<!-- data-glow schakelt de accentgloed achter de pagina. "off" is de stand sinds
|
|
|
|
|
|
0.5.5 (verzoek van de gebruiker, 04-09-2026); "on" zet hem terug, en dat is
|
|
|
|
|
|
het enige wat er voor nodig is: alles staat in de tokenlaag onder "Diepte" en
|
|
|
|
|
|
geen enkele regel hieronder kent de stand.
|
|
|
|
|
|
|
|
|
|
|
|
De gloed hangt aan de combinatie met data-depth="deep" en niet aan zichzelf.
|
|
|
|
|
|
Dat is een eis van de gebruiker en het is ook de enige zinnige lezing: de
|
|
|
|
|
|
gloed is de onderste van de vier dieptelagen, dus hij aanzetten terwijl de
|
|
|
|
|
|
pagina vlak staat zou een verloop achter dekkende vlakken leggen. -->
|
|
|
|
|
|
<html lang="en" data-theme="dark" data-accent="orange" data-depth="deep" data-glow="off">
|
2026-08-28 11:22:32 +02:00
|
|
|
|
<head>
|
|
|
|
|
|
<meta charset="UTF-8">
|
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
|
|
|
<title>Evolu Relay</title>
|
|
|
|
|
|
<link rel="icon" href="icon.png">
|
|
|
|
|
|
<style>
|
|
|
|
|
|
:root {
|
|
|
|
|
|
--radius-sm: 8px;
|
|
|
|
|
|
--radius-md: 14px;
|
|
|
|
|
|
--radius-lg: 20px;
|
2026-08-30 12:26:56 +02:00
|
|
|
|
--radius-xl: 28px;
|
2026-08-28 11:22:32 +02:00
|
|
|
|
--radius-full: 999px;
|
2026-08-30 12:26:56 +02:00
|
|
|
|
--font-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
|
|
|
|
--font-mono: 'SF Mono', 'Fira Code', 'Cascadia Mono', Consolas, monospace;
|
2026-08-28 11:22:32 +02:00
|
|
|
|
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
[data-theme="dark"][data-accent="orange"] {
|
2026-08-31 11:28:11 +02:00
|
|
|
|
--bg: #080808; --bg-elevated: #181818; --bg-raised: #242424; --bg-overlay: #303030;
|
|
|
|
|
|
--border: rgba(255,255,255,0.18); --border-mid: rgba(255,255,255,0.28);
|
|
|
|
|
|
--text-primary: #f2f2f2; --text-sec: #a3a3a3; --text-ter: #6a6a6a;
|
|
|
|
|
|
--accent: #FF6B00; --accent-soft: rgba(255,107,0,0.16); --accent-mid: rgba(255,107,0,0.26);
|
|
|
|
|
|
--accent-glow: rgba(255,107,0,0.30); --accent-text: #FF8124;
|
|
|
|
|
|
--pos: #39FF8A; --pos-bg: rgba(57,255,138,0.16); --neg: #ff5a5a; --neg-bg: rgba(255,69,69,0.18);
|
|
|
|
|
|
--neutral-bg: rgba(255,255,255,0.08); --input-bg: #1c1c1c; --scrollbar: #2c2c2c;
|
|
|
|
|
|
}
|
|
|
|
|
|
[data-theme="light"][data-accent="orange"] {
|
|
|
|
|
|
--bg: #ffffff; --bg-elevated: #f7f7f7; --bg-raised: #f0f0f0; --bg-overlay: #e8e8e8;
|
|
|
|
|
|
--border: rgba(0,0,0,0.12); --border-mid: rgba(0,0,0,0.18);
|
|
|
|
|
|
--text-primary: #0a0a0a; --text-sec: #888888; --text-ter: #bbbbbb;
|
|
|
|
|
|
--accent: #E05500; --accent-soft: rgba(224,85,0,0.08); --accent-mid: rgba(224,85,0,0.16);
|
|
|
|
|
|
--accent-glow: rgba(224,85,0,0.22); --accent-text: #E05500;
|
|
|
|
|
|
--pos: #007a48; --pos-bg: rgba(0,149,90,0.08); --neg: #cc2222; --neg-bg: rgba(204,34,34,0.08);
|
|
|
|
|
|
--neutral-bg: rgba(0,0,0,0.04); --input-bg: #f5f5f5; --scrollbar: #ddd;
|
|
|
|
|
|
}
|
|
|
|
|
|
[data-theme="dark"][data-accent="blue"] {
|
|
|
|
|
|
--bg: #080808; --bg-elevated: #181818; --bg-raised: #242424; --bg-overlay: #303030;
|
|
|
|
|
|
--border: rgba(255,255,255,0.18); --border-mid: rgba(255,255,255,0.28);
|
|
|
|
|
|
--text-primary: #f2f2f2; --text-sec: #a3a3a3; --text-ter: #6a6a6a;
|
|
|
|
|
|
--accent: #3B82F6; --accent-soft: rgba(59,130,246,0.16); --accent-mid: rgba(59,130,246,0.26);
|
|
|
|
|
|
--accent-glow: rgba(59,130,246,0.30); --accent-text: #6AA1FF;
|
|
|
|
|
|
--pos: #39FF8A; --pos-bg: rgba(57,255,138,0.16); --neg: #ff5a5a; --neg-bg: rgba(255,69,69,0.18);
|
|
|
|
|
|
--neutral-bg: rgba(255,255,255,0.08); --input-bg: #1c1c1c; --scrollbar: #2c2c2c;
|
|
|
|
|
|
}
|
|
|
|
|
|
[data-theme="light"][data-accent="blue"] {
|
|
|
|
|
|
--bg: #ffffff; --bg-elevated: #f7f7f7; --bg-raised: #f0f0f0; --bg-overlay: #e8e8e8;
|
|
|
|
|
|
--border: rgba(0,0,0,0.12); --border-mid: rgba(0,0,0,0.18);
|
|
|
|
|
|
--text-primary: #0a0a0a; --text-sec: #888888; --text-ter: #bbbbbb;
|
|
|
|
|
|
--accent: #2563EB; --accent-soft: rgba(37,99,235,0.08); --accent-mid: rgba(37,99,235,0.16);
|
|
|
|
|
|
--accent-glow: rgba(37,99,235,0.22); --accent-text: #2563EB;
|
|
|
|
|
|
--pos: #007a48; --pos-bg: rgba(0,149,90,0.08); --neg: #cc2222; --neg-bg: rgba(204,34,34,0.08);
|
|
|
|
|
|
--neutral-bg: rgba(0,0,0,0.04); --input-bg: #f5f5f5; --scrollbar: #ddd;
|
|
|
|
|
|
}
|
|
|
|
|
|
[data-theme="dark"][data-accent="lila"] {
|
|
|
|
|
|
--bg: #080808; --bg-elevated: #181818; --bg-raised: #242424; --bg-overlay: #303030;
|
|
|
|
|
|
--border: rgba(255,255,255,0.18); --border-mid: rgba(255,255,255,0.28);
|
|
|
|
|
|
--text-primary: #f2f2f2; --text-sec: #a3a3a3; --text-ter: #6a6a6a;
|
|
|
|
|
|
--accent: #B388FF; --accent-soft: rgba(179,136,255,0.16); --accent-mid: rgba(179,136,255,0.26);
|
|
|
|
|
|
--accent-glow: rgba(179,136,255,0.30); --accent-text: #C7A6FF;
|
|
|
|
|
|
--pos: #39FF8A; --pos-bg: rgba(57,255,138,0.16); --neg: #ff5a5a; --neg-bg: rgba(255,69,69,0.18);
|
|
|
|
|
|
--neutral-bg: rgba(255,255,255,0.08); --input-bg: #1c1c1c; --scrollbar: #2c2c2c;
|
|
|
|
|
|
}
|
|
|
|
|
|
[data-theme="light"][data-accent="lila"] {
|
|
|
|
|
|
--bg: #ffffff; --bg-elevated: #f7f7f7; --bg-raised: #f0f0f0; --bg-overlay: #e8e8e8;
|
|
|
|
|
|
--border: rgba(0,0,0,0.12); --border-mid: rgba(0,0,0,0.18);
|
|
|
|
|
|
--text-primary: #0a0a0a; --text-sec: #888888; --text-ter: #bbbbbb;
|
|
|
|
|
|
--accent: #7C3AED; --accent-soft: rgba(124,58,237,0.08); --accent-mid: rgba(124,58,237,0.16);
|
|
|
|
|
|
--accent-glow: rgba(124,58,237,0.22); --accent-text: #7C3AED;
|
|
|
|
|
|
--pos: #007a48; --pos-bg: rgba(0,149,90,0.08); --neg: #cc2222; --neg-bg: rgba(204,34,34,0.08);
|
|
|
|
|
|
--neutral-bg: rgba(0,0,0,0.04); --input-bg: #f5f5f5; --scrollbar: #ddd;
|
|
|
|
|
|
}
|
|
|
|
|
|
[data-theme="dark"][data-accent="teal"] {
|
|
|
|
|
|
--bg: #080808; --bg-elevated: #181818; --bg-raised: #242424; --bg-overlay: #303030;
|
|
|
|
|
|
--border: rgba(255,255,255,0.18); --border-mid: rgba(255,255,255,0.28);
|
|
|
|
|
|
--text-primary: #f2f2f2; --text-sec: #a3a3a3; --text-ter: #6a6a6a;
|
|
|
|
|
|
--accent: #14B8A6; --accent-soft: rgba(20,184,166,0.16); --accent-mid: rgba(20,184,166,0.26);
|
|
|
|
|
|
--accent-glow: rgba(20,184,166,0.30); --accent-text: #2DD4BF;
|
|
|
|
|
|
--pos: #39FF8A; --pos-bg: rgba(57,255,138,0.16); --neg: #ff5a5a; --neg-bg: rgba(255,69,69,0.18);
|
|
|
|
|
|
--neutral-bg: rgba(255,255,255,0.08); --input-bg: #1c1c1c; --scrollbar: #2c2c2c;
|
|
|
|
|
|
}
|
|
|
|
|
|
[data-theme="light"][data-accent="teal"] {
|
|
|
|
|
|
--bg: #ffffff; --bg-elevated: #f7f7f7; --bg-raised: #f0f0f0; --bg-overlay: #e8e8e8;
|
|
|
|
|
|
--border: rgba(0,0,0,0.12); --border-mid: rgba(0,0,0,0.18);
|
|
|
|
|
|
--text-primary: #0a0a0a; --text-sec: #888888; --text-ter: #bbbbbb;
|
|
|
|
|
|
--accent: #0D9488; --accent-soft: rgba(13,148,136,0.08); --accent-mid: rgba(13,148,136,0.16);
|
|
|
|
|
|
--accent-glow: rgba(13,148,136,0.22); --accent-text: #0D9488;
|
|
|
|
|
|
--pos: #007a48; --pos-bg: rgba(0,149,90,0.08); --neg: #cc2222; --neg-bg: rgba(204,34,34,0.08);
|
|
|
|
|
|
--neutral-bg: rgba(0,0,0,0.04); --input-bg: #f5f5f5; --scrollbar: #ddd;
|
|
|
|
|
|
}
|
|
|
|
|
|
[data-theme="dark"][data-accent="magenta"] {
|
|
|
|
|
|
--bg: #080808; --bg-elevated: #181818; --bg-raised: #242424; --bg-overlay: #303030;
|
|
|
|
|
|
--border: rgba(255,255,255,0.18); --border-mid: rgba(255,255,255,0.28);
|
|
|
|
|
|
--text-primary: #f2f2f2; --text-sec: #a3a3a3; --text-ter: #6a6a6a;
|
|
|
|
|
|
--accent: #E24DAE; --accent-soft: rgba(226,77,174,0.16); --accent-mid: rgba(226,77,174,0.26);
|
|
|
|
|
|
--accent-glow: rgba(226,77,174,0.30); --accent-text: #F072C4;
|
|
|
|
|
|
--pos: #39FF8A; --pos-bg: rgba(57,255,138,0.16); --neg: #ff5a5a; --neg-bg: rgba(255,69,69,0.18);
|
|
|
|
|
|
--neutral-bg: rgba(255,255,255,0.08); --input-bg: #1c1c1c; --scrollbar: #2c2c2c;
|
|
|
|
|
|
}
|
|
|
|
|
|
[data-theme="light"][data-accent="magenta"] {
|
|
|
|
|
|
--bg: #ffffff; --bg-elevated: #f7f7f7; --bg-raised: #f0f0f0; --bg-overlay: #e8e8e8;
|
|
|
|
|
|
--border: rgba(0,0,0,0.12); --border-mid: rgba(0,0,0,0.18);
|
|
|
|
|
|
--text-primary: #0a0a0a; --text-sec: #888888; --text-ter: #bbbbbb;
|
|
|
|
|
|
--accent: #BE2A86; --accent-soft: rgba(190,42,134,0.08); --accent-mid: rgba(190,42,134,0.16);
|
|
|
|
|
|
--accent-glow: rgba(190,42,134,0.22); --accent-text: #BE2A86;
|
|
|
|
|
|
--pos: #007a48; --pos-bg: rgba(0,149,90,0.08); --neg: #cc2222; --neg-bg: rgba(204,34,34,0.08);
|
|
|
|
|
|
--neutral-bg: rgba(0,0,0,0.04); --input-bg: #f5f5f5; --scrollbar: #ddd;
|
2026-08-28 11:22:32 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-31 11:28:11 +02:00
|
|
|
|
/* -- Diepte ------------------------------------------------------------------
|
|
|
|
|
|
Overgenomen uit HomeGit/Docs/website-design-system.html (31-08-2026). In
|
2026-09-04 09:55:11 +02:00
|
|
|
|
AssetApp is dit een instelling; hier is er geen schuif, want dit is een
|
|
|
|
|
|
statuspagina met dark/light en verder geen knoppen. De stand staat dus in het
|
|
|
|
|
|
html-element en niet in een menu.
|
2026-08-31 11:28:11 +02:00
|
|
|
|
|
2026-09-04 09:55:11 +02:00
|
|
|
|
**De gloed staat sinds 0.5.5 uit** (verzoek van de gebruiker, 04-09-2026).
|
|
|
|
|
|
Aanzetten is `data-glow="on"` op het html-element en verder niets: het blok
|
|
|
|
|
|
verderop zet
|
|
|
|
|
|
dan de twee verlopen én de blur. Dat blok eist ook `data-depth="deep"`, want
|
|
|
|
|
|
een gloed achter dekkende vlakken is geen stand die iemand bedoelt.
|
|
|
|
|
|
|
|
|
|
|
|
Dat de blur aan de gloed hangt is geen gemak maar de kern van hoe dit werkt.
|
|
|
|
|
|
Een backdrop-filter op een egale ondergrond levert per definitie niets op, dus
|
|
|
|
|
|
zonder gloed is de blur rekenwerk zonder resultaat: de gloed is het eerste
|
|
|
|
|
|
deel en het glas het tweede, in die volgorde.
|
|
|
|
|
|
|
|
|
|
|
|
Wat in deze app níet aan de diepte hangt: de doorschijnende kleur van de
|
|
|
|
|
|
vlakken en het randlicht staan onder [data-theme] en dus altijd aan. Dat is zo
|
|
|
|
|
|
sinds 0.5.2 en het blijft zo; in het designsysteem hangen die wél aan de
|
|
|
|
|
|
diepte, omdat daar een schuif zit die ze allemaal tegelijk omzet. */
|
2026-08-31 11:28:11 +02:00
|
|
|
|
:root {
|
|
|
|
|
|
--surface: var(--bg-elevated);
|
|
|
|
|
|
--surface-inset: var(--bg-raised);
|
2026-09-04 09:55:11 +02:00
|
|
|
|
--surface-blur: 0px;
|
|
|
|
|
|
--surface-sat: 100%;
|
2026-08-31 11:28:11 +02:00
|
|
|
|
--edge: none;
|
|
|
|
|
|
--ambient: none;
|
|
|
|
|
|
--shadow-1: 0 1px 2px rgba(0,0,0,0.06), 0 4px 16px rgba(0,0,0,0.05);
|
|
|
|
|
|
}
|
|
|
|
|
|
[data-theme="dark"] {
|
|
|
|
|
|
--surface: color-mix(in srgb, #1c1c1c 78%, transparent);
|
|
|
|
|
|
--surface-inset: color-mix(in srgb, #262626 76%, transparent);
|
|
|
|
|
|
--edge: linear-gradient(180deg, rgba(255,255,255,0.10), rgba(255,255,255,0) 42%);
|
|
|
|
|
|
--shadow-1: 0 1px 2px rgba(0,0,0,0.35), 0 10px 30px -18px rgba(0,0,0,0.9);
|
|
|
|
|
|
}
|
|
|
|
|
|
[data-theme="light"] {
|
|
|
|
|
|
--surface: color-mix(in srgb, #ffffff 72%, transparent);
|
|
|
|
|
|
--surface-inset: color-mix(in srgb, #f2f2f2 74%, transparent);
|
|
|
|
|
|
--edge: linear-gradient(180deg, rgba(255,255,255,0.85), rgba(255,255,255,0) 46%);
|
|
|
|
|
|
}
|
2026-09-04 09:55:11 +02:00
|
|
|
|
/* De gloed: twee verlopen, één uit de linkerbovenhoek en een tegenlichtje
|
|
|
|
|
|
rechtsboven, zodat het geheel niet als één vlek leest. De blur hoort erbij en
|
|
|
|
|
|
staat daarom in ditzelfde blok; zie de uitleg bovenaan.
|
|
|
|
|
|
|
|
|
|
|
|
De selector heeft twee attributen en dus een specificiteit die `:root` niet
|
|
|
|
|
|
haalt. Dat is de reden dat de media-query hieronder deze selector letterlijk
|
|
|
|
|
|
herhaalt: met alleen `:root` erin zou de wens "minder doorzichtigheid" de blur
|
|
|
|
|
|
niet meer uit krijgen, en dat is een stille regressie in precies de stand waar
|
|
|
|
|
|
iemand om vroeg. */
|
|
|
|
|
|
[data-depth="deep"][data-glow="on"] {
|
|
|
|
|
|
--surface-blur: 16px;
|
|
|
|
|
|
--surface-sat: 135%;
|
2026-08-31 11:28:11 +02:00
|
|
|
|
--ambient: radial-gradient(58rem 34rem at 8% -8%, var(--accent-glow), transparent 62%),
|
|
|
|
|
|
radial-gradient(46rem 30rem at 102% 4%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 66%);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* De twee toegankelijkheidswensen horen NIET in een gedeelde media-query, en dat
|
|
|
|
|
|
is een les die een ronde kostte: Windows heeft een schakelaar "Animatie-
|
|
|
|
|
|
effecten" die bij veel mensen uit staat, en die zet prefers-reduced-motion op
|
|
|
|
|
|
reduce. Samen in een query deed de hele stand dan niets. Ze zeggen ook echt
|
|
|
|
|
|
iets anders: de ene gaat over doorschijnende vlakken, de andere over
|
|
|
|
|
|
beweging. */
|
|
|
|
|
|
@media (prefers-reduced-transparency: reduce) {
|
2026-09-04 09:55:11 +02:00
|
|
|
|
/* Twee selectors, want de gloedstand hierboven heeft twee attributen en wint
|
|
|
|
|
|
anders van `:root`. Gelijke specificiteit plus later in het bestand is wat
|
|
|
|
|
|
hem hier laat winnen. */
|
|
|
|
|
|
:root, [data-depth="deep"][data-glow="on"] {
|
2026-08-31 11:28:11 +02:00
|
|
|
|
--surface: var(--bg-elevated); --surface-inset: var(--bg-raised);
|
|
|
|
|
|
--surface-blur: 0px; --surface-sat: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
|
|
|
|
body::before, .card { transition: none; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-04 09:55:11 +02:00
|
|
|
|
/* Hier stond een media-query die op een scherm smaller dan 900 pixels terugviel
|
|
|
|
|
|
op de vlakke stand (keuze gebruiker, 31-08-2026): de gloed is 58rem breed en
|
|
|
|
|
|
op een telefoon dus breder dan het scherm, waardoor het geen verloop meer was
|
|
|
|
|
|
maar een egale waas.
|
2026-08-31 11:47:30 +02:00
|
|
|
|
|
2026-09-04 09:55:11 +02:00
|
|
|
|
Weggehaald op 04-09-2026, toen de gloed er in het geheel uit ging: hij zette
|
|
|
|
|
|
de gloed uit (die nu al uit staat), de blur uit (die met de gloed meegaat) en
|
|
|
|
|
|
de vlakken op dekkend (het enige dat overbleef, en dat is zonder blur alleen
|
|
|
|
|
|
nog een iets andere grijstoon). Een breekpunt dat in beide standen hetzelfde
|
|
|
|
|
|
oplevert, houdt alleen de suggestie over dat er iets gebeurt. Zet je de gloed
|
|
|
|
|
|
met `data-glow="on"` terug, dan hoort dit blok er ook weer bij. */
|
2026-08-31 11:47:30 +02:00
|
|
|
|
|
2026-08-31 11:28:11 +02:00
|
|
|
|
/* De gloed ligt achter alles en vangt geen klikken. z-index 0 hier en 1 op .wrap:
|
|
|
|
|
|
zonder stapelcontext kan een fixed pseudo-element van body over de inhoud heen
|
|
|
|
|
|
komen te liggen. */
|
|
|
|
|
|
body::before {
|
|
|
|
|
|
content: ''; position: fixed; inset: 0; z-index: 0; pointer-events: none;
|
|
|
|
|
|
background: var(--ambient); transition: background 0.3s;
|
2026-08-28 11:22:32 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-30 12:26:56 +02:00
|
|
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
2026-08-28 11:22:32 +02:00
|
|
|
|
|
|
|
|
|
|
body {
|
2026-08-30 12:26:56 +02:00
|
|
|
|
font-family: var(--font-base);
|
2026-08-28 11:22:32 +02:00
|
|
|
|
background: var(--bg);
|
|
|
|
|
|
color: var(--text-primary);
|
|
|
|
|
|
-webkit-font-smoothing: antialiased;
|
2026-08-30 12:26:56 +02:00
|
|
|
|
padding: 2rem 1.75rem 3rem;
|
2026-08-28 11:22:32 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-30 12:26:56 +02:00
|
|
|
|
/* Dezelfde maat als Electrum Gate. Breed, maar niet oneindig: op een ultrawide
|
|
|
|
|
|
monitor zou een kader van 2500px regels opleveren die niemand leest. */
|
2026-08-31 11:28:11 +02:00
|
|
|
|
.wrap { position: relative; z-index: 1; max-width: 1760px; margin: 0 auto; }
|
2026-08-28 11:22:32 +02:00
|
|
|
|
|
2026-08-30 12:26:56 +02:00
|
|
|
|
/* ── Het raster ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
Twaalf kolommen, en de kaders vullen ze in de volgorde waarin ze in de HTML
|
|
|
|
|
|
staan. Die volgorde is de bedoelde leesvolgorde: bovenaan wat je nakomt,
|
|
|
|
|
|
onderaan wat je één keer instelt. Bij een smal scherm wordt elk kader vol breed
|
|
|
|
|
|
en blijft die volgorde staan. */
|
|
|
|
|
|
.grid {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: repeat(12, 1fr);
|
|
|
|
|
|
gap: 1.5rem;
|
|
|
|
|
|
align-items: stretch;
|
|
|
|
|
|
}
|
|
|
|
|
|
.grid > * { min-width: 0; }
|
|
|
|
|
|
.grid > .card { display: flex; flex-direction: column; }
|
|
|
|
|
|
.col-6 { grid-column: span 6; }
|
|
|
|
|
|
.col-full { grid-column: 1 / -1; }
|
|
|
|
|
|
.grid .card { margin-bottom: 0; }
|
|
|
|
|
|
@media (max-width: 1000px) { .col-6 { grid-column: 1 / -1; } }
|
|
|
|
|
|
|
|
|
|
|
|
/* ── Typografie ─────────────────────────────────────────────────────────── */
|
|
|
|
|
|
.t-h1 { font-size: 2rem; font-weight: 700; letter-spacing: -0.025em; line-height: 1.15; }
|
|
|
|
|
|
.t-h3 { font-size: 1.1rem; font-weight: 600; letter-spacing: -0.01em; line-height: 1.3; }
|
|
|
|
|
|
.t-body-sm { font-size: 0.82rem; line-height: 1.6; color: var(--text-sec); }
|
|
|
|
|
|
.t-mono { font-family: var(--font-mono); font-size: 0.82rem; color: var(--accent); }
|
|
|
|
|
|
|
|
|
|
|
|
/* ── Kop ────────────────────────────────────────────────────────────────── */
|
|
|
|
|
|
.head { display: flex; align-items: flex-start; gap: 1rem; margin-bottom: 1.75rem; }
|
|
|
|
|
|
.head-text { flex: 1; min-width: 0; }
|
|
|
|
|
|
.head-text p { margin-top: 0.35rem; }
|
|
|
|
|
|
/* Het app-icoon, op de maat van Electrum Gate. Het bestand komt uit de app-map en
|
|
|
|
|
|
wordt door de compose in de webroot gemount; het merkje blijft als terugval
|
|
|
|
|
|
staan voor het geval die mount er niet is, want een gebroken-afbeeldingicoon in
|
|
|
|
|
|
de kop is erger dan een generiek teken. */
|
|
|
|
|
|
.mark {
|
|
|
|
|
|
width: 64px; height: 64px; border-radius: var(--radius-md);
|
|
|
|
|
|
background: var(--accent-soft); color: var(--accent);
|
|
|
|
|
|
display: grid; place-items: center; flex-shrink: 0;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
.mark img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|
|
|
|
|
.mark svg { width: 32px; height: 32px; }
|
|
|
|
|
|
|
|
|
|
|
|
/* Het versienummer, klein achter de tagline. Deze store verloor een keer een dag
|
|
|
|
|
|
aan een wijziging die niet uitrolde door een niet-verhoogde version, en dan is
|
|
|
|
|
|
"welke versie zie ik nu" precies de vraag die je stelt.
|
|
|
|
|
|
|
|
|
|
|
|
text-sec en niet text-ter: die laatste is in de lichte variant #bbbbbb op wit en
|
|
|
|
|
|
dat is op deze grootte niet te lezen. */
|
|
|
|
|
|
.head-version {
|
|
|
|
|
|
margin-left: 0.35rem;
|
|
|
|
|
|
font-size: 0.72rem;
|
|
|
|
|
|
color: var(--text-sec);
|
|
|
|
|
|
font-variant-numeric: tabular-nums;
|
|
|
|
|
|
white-space: nowrap;
|
2026-08-28 11:22:32 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-30 12:26:56 +02:00
|
|
|
|
/* ── Kaarten ────────────────────────────────────────────────────────────── */
|
|
|
|
|
|
.card {
|
2026-08-31 11:28:11 +02:00
|
|
|
|
position: relative;
|
|
|
|
|
|
background: var(--surface);
|
|
|
|
|
|
/* --radius-lg en niet --radius-xl (31-08-2026): elk buitenste kader heeft
|
|
|
|
|
|
dezelfde hoek, want ze staan in beeld naast en om elkaar heen en twee radii
|
|
|
|
|
|
lezen dan als een fout. Het logkader leidt zijn hoek hiervan af. */
|
|
|
|
|
|
--radius: var(--radius-lg);
|
2026-08-30 12:26:56 +02:00
|
|
|
|
--padding: 1.8rem;
|
|
|
|
|
|
border-radius: var(--radius);
|
|
|
|
|
|
padding: var(--padding);
|
|
|
|
|
|
margin-bottom: 1rem;
|
2026-08-31 11:28:11 +02:00
|
|
|
|
box-shadow: var(--shadow-1);
|
|
|
|
|
|
backdrop-filter: blur(var(--surface-blur)) saturate(var(--surface-sat));
|
|
|
|
|
|
-webkit-backdrop-filter: blur(var(--surface-blur)) saturate(var(--surface-sat));
|
|
|
|
|
|
transition: background 0.25s, box-shadow 0.25s;
|
2026-08-30 12:26:56 +02:00
|
|
|
|
}
|
2026-08-31 11:28:11 +02:00
|
|
|
|
/* Het randlicht: een verloop van licht naar niets over de bovenste 40 procent.
|
|
|
|
|
|
Dit is het verschil tussen doorzichtig en glas; zonder deze laag ziet een
|
|
|
|
|
|
halftransparante kaart er alleen bleek uit. De inhoud moet erbovenop blijven,
|
|
|
|
|
|
anders ligt er een waas over de tekst in plaats van erachter. */
|
|
|
|
|
|
.card::before {
|
|
|
|
|
|
content: ''; position: absolute; inset: 0; border-radius: inherit;
|
|
|
|
|
|
background: var(--edge); pointer-events: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
.card > * { position: relative; }
|
2026-08-30 12:26:56 +02:00
|
|
|
|
[data-theme="dark"] .card { border: 1px solid var(--border); }
|
|
|
|
|
|
/* Rand én schaduw in het lichte thema: een zachte schaduw op wit geeft wel diepte
|
|
|
|
|
|
maar geen begrenzing. Dezelfde waarden als Electrum Gate. */
|
|
|
|
|
|
[data-theme="light"] .card {
|
|
|
|
|
|
border: 1px solid var(--border);
|
|
|
|
|
|
}
|
|
|
|
|
|
.card-head {
|
|
|
|
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
|
|
|
|
gap: 1rem; margin-bottom: 1.1rem;
|
|
|
|
|
|
}
|
2026-08-28 11:22:32 +02:00
|
|
|
|
|
2026-08-30 12:26:56 +02:00
|
|
|
|
/* ── De bovenste rij ────────────────────────────────────────────────────────
|
|
|
|
|
|
Vier gelijke widgets. Een eigen raster en geen deling van de twaalf kolommen,
|
|
|
|
|
|
want dat is wat Electrum Gate met zijn vijf ook doet.
|
2026-08-28 11:22:32 +02:00
|
|
|
|
|
2026-08-30 12:26:56 +02:00
|
|
|
|
Wat er bij het versmallen gebeurt is expliciet en niet aan auto-fit gelaten: bij
|
|
|
|
|
|
vier wil je twee plus twee en geen drie plus één, want dan staat er één widget
|
|
|
|
|
|
alleen op een regel zonder dat daar een reden voor is. */
|
|
|
|
|
|
.row1 {
|
|
|
|
|
|
grid-column: 1 / -1;
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: repeat(4, 1fr);
|
|
|
|
|
|
gap: 1.5rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
/* 1400 en niet 1200, en dat is gemeten en geen smaak: onder deze breedte is een
|
|
|
|
|
|
vierde van de rij te smal voor de kop "New owners" naast de twee knoppen die er
|
|
|
|
|
|
in beeld komen. Die knoppen zijn onzichtbaar maar niet weg, want ze staan op
|
|
|
|
|
|
opacity en niet op display:none, en ze houden dus hun ruimte. Werd de kop dan
|
|
|
|
|
|
twee regels hoog, dan stond de waarde in dat ene kader lager dan in de drie
|
|
|
|
|
|
andere. Zelfde grens als de rij van vijf bij Electrum Gate. */
|
|
|
|
|
|
@media (max-width: 1400px) { .row1 { grid-template-columns: repeat(2, 1fr); } }
|
|
|
|
|
|
@media (max-width: 560px) { .row1 { grid-template-columns: 1fr; } }
|
|
|
|
|
|
|
|
|
|
|
|
/* De widgets zetten hun waarde onderaan, zodat de vier waarden op één lijn staan
|
|
|
|
|
|
ook als de ene kop over twee regels valt en de andere niet. */
|
|
|
|
|
|
.row1 .card { display: flex; flex-direction: column; min-height: 12rem; }
|
|
|
|
|
|
.row1 .stat-body { margin-top: auto; }
|
|
|
|
|
|
|
|
|
|
|
|
/* Eén maat voor alle widgets: een getal en een woord zijn hier even belangrijk en
|
|
|
|
|
|
horen dus niet van formaat te verschillen. */
|
|
|
|
|
|
.stat-val {
|
|
|
|
|
|
font-size: 1.55rem; font-weight: 700; letter-spacing: -0.025em;
|
|
|
|
|
|
font-variant-numeric: tabular-nums; line-height: 1.2;
|
|
|
|
|
|
}
|
|
|
|
|
|
.stat-val.unknown { color: var(--text-ter); }
|
|
|
|
|
|
.stat-val.text { overflow-wrap: anywhere; }
|
|
|
|
|
|
.stat-val.bad { color: var(--neg); }
|
2026-08-31 11:47:30 +02:00
|
|
|
|
/* De accentkleur en niet --pos (keuze gebruiker, 31-08-2026). "Running" is de
|
|
|
|
|
|
eigen toestand van de app en geen positief cijfer, dus het accent hoort erbij;
|
|
|
|
|
|
--pos betekent sinds het gedeelde palet "het staat in de plus" en is groen. Zo
|
|
|
|
|
|
blijven de twee betekenissen uit elkaar en volgt deze tekst het accent mee als
|
|
|
|
|
|
de app ooit een andere kleur krijgt. */
|
|
|
|
|
|
.stat-val.ok { color: var(--accent-text); }
|
2026-08-30 12:26:56 +02:00
|
|
|
|
/* Even hoog als bij Electrum Gate, zodat de waarden van beide apps op dezelfde
|
|
|
|
|
|
lijn liggen als je ze naast elkaar zet. De tekst staat onderin dat vak. */
|
|
|
|
|
|
.stat-sub {
|
|
|
|
|
|
font-size: 0.75rem; color: var(--text-sec);
|
|
|
|
|
|
margin-top: 0.5rem; min-height: 34px;
|
|
|
|
|
|
display: grid; align-items: end;
|
|
|
|
|
|
}
|
|
|
|
|
|
.stat-sub.mono { font-family: var(--font-mono); overflow-wrap: anywhere; }
|
|
|
|
|
|
|
|
|
|
|
|
/* ── Meldingen ──────────────────────────────────────────────────────────── */
|
|
|
|
|
|
.alert {
|
|
|
|
|
|
display: flex; gap: 0.8rem; align-items: flex-start;
|
|
|
|
|
|
padding: 1rem 1.2rem; border-radius: var(--radius-md);
|
|
|
|
|
|
font-size: 0.84rem; margin-bottom: 0.75rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.alert-error { background: var(--neg-bg); color: var(--neg); }
|
|
|
|
|
|
.alert-warn { background: var(--accent-soft); color: var(--accent-text); }
|
|
|
|
|
|
.alert-neutral { background: var(--neutral-bg); color: var(--text-sec); border: 1px solid var(--border); }
|
|
|
|
|
|
.alert-icon { flex-shrink: 0; margin-top: 0.1rem; }
|
|
|
|
|
|
.alert-icon svg { width: 16px; height: 16px; display: block; }
|
|
|
|
|
|
|
|
|
|
|
|
/* ── Het adres van de relay ─────────────────────────────────────────────── */
|
|
|
|
|
|
.url-box {
|
|
|
|
|
|
font-family: var(--font-mono);
|
|
|
|
|
|
font-size: 0.9rem;
|
2026-08-28 11:22:32 +02:00
|
|
|
|
background: var(--bg-raised);
|
|
|
|
|
|
border: 1px solid var(--border);
|
2026-08-31 11:28:11 +02:00
|
|
|
|
border-radius: max(4px, calc(var(--radius-lg) - 1.8rem));
|
2026-08-30 12:26:56 +02:00
|
|
|
|
padding: 0.9rem 1.1rem;
|
|
|
|
|
|
overflow-wrap: anywhere;
|
|
|
|
|
|
color: var(--accent);
|
2026-08-28 11:22:32 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-30 12:26:56 +02:00
|
|
|
|
/* ── Lijstregels ────────────────────────────────────────────────────────── */
|
|
|
|
|
|
.list-item {
|
|
|
|
|
|
display: flex; justify-content: space-between; align-items: center;
|
|
|
|
|
|
gap: 1rem; padding: 0.85rem 0; border-bottom: 1px solid var(--border);
|
|
|
|
|
|
}
|
|
|
|
|
|
.list-item:last-child { border-bottom: none; }
|
2026-09-10 09:54:22 +02:00
|
|
|
|
/* Elke rij is twee regels hoog, in alle lijsten, en dat wordt hier dichtgehouden
|
|
|
|
|
|
op de drie manieren waarop het alsnog kon verschuiven: een lange waarde die
|
|
|
|
|
|
afbreekt naar een derde regel (afkappen met een puntje, de volledige waarde
|
|
|
|
|
|
blijft als tooltip), een onderregel zonder inhoud (min-height reserveert de
|
|
|
|
|
|
regel), en het invoerveld tijdens labelen, dat lager is dan twee regels tekst
|
|
|
|
|
|
(min-height op owner-who). Waarom dit een eis is: de hoogte veranderde zodra je
|
|
|
|
|
|
een rij labelde. Verzoek van de gebruiker, 10-09-2026. */
|
|
|
|
|
|
.list-item-title {
|
|
|
|
|
|
font-size: 0.88rem; font-weight: 500; line-height: 1.5;
|
|
|
|
|
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
|
|
|
|
}
|
2026-08-30 12:26:56 +02:00
|
|
|
|
.list-item-title.mono { font-family: var(--font-mono); font-size: 0.8rem; font-weight: 400; }
|
2026-09-10 09:54:22 +02:00
|
|
|
|
.list-item-sub {
|
|
|
|
|
|
font-size: 0.72rem; color: var(--text-sec); margin-top: 0.1rem; line-height: 1.5;
|
|
|
|
|
|
min-height: 1.5em;
|
|
|
|
|
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
|
|
|
|
}
|
2026-08-30 12:26:56 +02:00
|
|
|
|
.list-item-sub.mono { font-family: var(--font-mono); }
|
|
|
|
|
|
.list-empty { font-size: 0.78rem; color: var(--text-ter); padding: 0.7rem 0; }
|
2026-09-10 09:54:22 +02:00
|
|
|
|
/* 0.88 × 1.5 voor de titel, 0.72 × 1.5 plus 0.1 marge voor de onderregel. */
|
|
|
|
|
|
.owner-who { min-width: 0; min-height: 2.5rem; }
|
2026-08-30 12:26:56 +02:00
|
|
|
|
/* De badge duwt zichzelf naar rechts en de knoppen komen erachter. Zo staat de
|
|
|
|
|
|
badge op elke regel op dezelfde plek, ook op een regel zonder knoppen in beeld. */
|
|
|
|
|
|
.owner-tags { display: flex; gap: 0.5rem; flex-shrink: 0; margin-left: auto; }
|
|
|
|
|
|
.owner-tags:empty { display: none; }
|
|
|
|
|
|
.owner-actions { display: flex; gap: 0.5rem; flex-shrink: 0; }
|
|
|
|
|
|
|
|
|
|
|
|
/* ── Knoppen ────────────────────────────────────────────────────────────── */
|
|
|
|
|
|
.btn {
|
|
|
|
|
|
display: inline-flex; align-items: center; gap: 0.4rem;
|
|
|
|
|
|
font-family: inherit; font-size: 0.75rem; font-weight: 600;
|
|
|
|
|
|
padding: 0.45rem 0.9rem; border-radius: var(--radius-sm);
|
|
|
|
|
|
border: 1px solid var(--border-mid); background: transparent;
|
|
|
|
|
|
color: var(--text-primary); cursor: pointer;
|
|
|
|
|
|
transition: background 0.2s var(--ease-out);
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
.btn:hover { background: var(--neutral-bg); }
|
|
|
|
|
|
.btn:active { transform: scale(0.97); }
|
|
|
|
|
|
.btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
2026-09-09 15:45:55 +02:00
|
|
|
|
|
|
|
|
|
|
/* Bevroren terwijl er een opdracht onderweg is. Alleen de lijsten, want alleen
|
|
|
|
|
|
daar staan knoppen die iets veranderen, en de kaders erboven horen gewoon te
|
|
|
|
|
|
blijven bijwerken. Geen verschuiving en geen overlay: dit verandert niets aan
|
|
|
|
|
|
de afmetingen, en dat is precies waarom het de melding bovenaan vervangt. */
|
|
|
|
|
|
body.is-busy .list-item { opacity: 0.55; }
|
|
|
|
|
|
body.is-busy .list-item button { cursor: progress; }
|
2026-08-30 12:26:56 +02:00
|
|
|
|
.btn-solid { background: var(--accent); color: #fff; border-color: transparent; }
|
|
|
|
|
|
.btn-solid:hover { background: var(--accent); opacity: 0.88; }
|
|
|
|
|
|
.btn-danger { color: var(--neg); border-color: color-mix(in srgb, var(--neg) 45%, transparent); }
|
|
|
|
|
|
.btn-danger:hover { background: var(--neg-bg); }
|
|
|
|
|
|
|
|
|
|
|
|
/* Knoppen verschijnen pas bij hover, op verzoek van de gebruiker (30-08-2026).
|
|
|
|
|
|
Twee plekken met dezelfde regel: per lijstregel, en per kader voor de knoppen
|
|
|
|
|
|
die over het kader als geheel gaan.
|
|
|
|
|
|
|
|
|
|
|
|
Opacity en geen display:none, want dan blijven ze met de tab-toets bereikbaar;
|
|
|
|
|
|
focus-within maakt ze dan alsnog zichtbaar. Op aanraakschermen bestaat hover
|
|
|
|
|
|
niet, dus daar staan ze altijd aan. Zonder die laatste regel zijn de knoppen op
|
|
|
|
|
|
een telefoon onbereikbaar, en dat is niet "minder mooi" maar stuk. */
|
|
|
|
|
|
.hover-only {
|
|
|
|
|
|
opacity: 0;
|
|
|
|
|
|
transition: opacity 0.18s var(--ease-out);
|
|
|
|
|
|
}
|
|
|
|
|
|
.list-item:hover .hover-only,
|
|
|
|
|
|
.list-item:focus-within .hover-only,
|
|
|
|
|
|
.card:hover > .card-head > .hover-only,
|
|
|
|
|
|
.card:focus-within > .card-head > .hover-only { opacity: 1; }
|
|
|
|
|
|
@media (hover: none) {
|
|
|
|
|
|
.hover-only { opacity: 1; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ── Het labelveld ────────────────────────────────────────────────────────
|
|
|
|
|
|
Een eigenaar-id is een reeks tekens zonder betekenis. Het label is wat de
|
|
|
|
|
|
gebruiker eraan hangt, en dit is het veld waarin dat gebeurt: hij komt in de
|
|
|
|
|
|
plaats van de titel van de regel zolang je bezig bent. */
|
|
|
|
|
|
.label-input {
|
|
|
|
|
|
width: min(22rem, 100%);
|
|
|
|
|
|
font-family: inherit; font-size: 0.85rem;
|
|
|
|
|
|
color: var(--text-primary);
|
|
|
|
|
|
background: var(--bg-raised);
|
|
|
|
|
|
border: 1px solid var(--accent);
|
|
|
|
|
|
border-radius: var(--radius-sm);
|
|
|
|
|
|
padding: 0.4rem 0.55rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.label-input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
|
|
|
|
|
|
|
|
|
|
/* ── Badges ─────────────────────────────────────────────────────────────── */
|
|
|
|
|
|
.badge {
|
|
|
|
|
|
display: inline-flex; align-items: center;
|
|
|
|
|
|
font-size: 0.7rem; font-weight: 600;
|
|
|
|
|
|
padding: 0.25rem 0.65rem; border-radius: var(--radius-full);
|
|
|
|
|
|
letter-spacing: 0.02em; white-space: nowrap;
|
|
|
|
|
|
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
|
|
|
|
|
|
}
|
|
|
|
|
|
.badge-pos { background: var(--pos-bg); color: var(--pos); }
|
|
|
|
|
|
.badge-neg { background: var(--neg-bg); color: var(--neg); }
|
|
|
|
|
|
|
|
|
|
|
|
/* ── Het menu rechtsboven ───────────────────────────────────────────────── */
|
|
|
|
|
|
.menu { position: relative; flex-shrink: 0; }
|
|
|
|
|
|
.menu > summary { list-style: none; cursor: pointer; }
|
|
|
|
|
|
.menu > summary::-webkit-details-marker { display: none; }
|
|
|
|
|
|
.menu > summary::marker { content: ''; }
|
|
|
|
|
|
/* Geen knoplook: alleen de drie punten, met een accent bij hover. Flink van
|
|
|
|
|
|
formaat, want drie punten op de gewone knopgrootte zijn nauwelijks te zien en al
|
|
|
|
|
|
helemaal niet te raken. De letterafstand zet ze uit elkaar. */
|
|
|
|
|
|
.menu-button {
|
|
|
|
|
|
display: inline-flex; align-items: center; justify-content: center;
|
|
|
|
|
|
font-size: 1.4rem;
|
|
|
|
|
|
line-height: 1;
|
|
|
|
|
|
letter-spacing: 0.08em;
|
|
|
|
|
|
padding: 0.25rem 0.6rem 0.45rem;
|
|
|
|
|
|
min-height: 2rem;
|
|
|
|
|
|
color: var(--text-sec);
|
|
|
|
|
|
border-radius: var(--radius-sm);
|
|
|
|
|
|
transition: color 0.2s var(--ease-out);
|
|
|
|
|
|
}
|
|
|
|
|
|
.menu-button:hover { color: var(--accent); }
|
|
|
|
|
|
.menu[open] > .menu-button { color: var(--accent); }
|
|
|
|
|
|
.menu-button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
|
|
|
|
.menu-panel {
|
|
|
|
|
|
position: absolute; right: 0; top: calc(100% + 0.4rem);
|
|
|
|
|
|
z-index: 20; min-width: 15rem;
|
|
|
|
|
|
background: var(--bg-elevated);
|
|
|
|
|
|
border: 1px solid var(--border);
|
|
|
|
|
|
border-radius: var(--radius-md);
|
|
|
|
|
|
padding: 0.35rem;
|
|
|
|
|
|
box-shadow: 0 8px 30px rgba(0,0,0,0.18);
|
|
|
|
|
|
}
|
|
|
|
|
|
.menu-item {
|
|
|
|
|
|
display: flex; align-items: center; justify-content: space-between; gap: 1.2rem;
|
|
|
|
|
|
width: 100%; text-align: left;
|
|
|
|
|
|
font-family: inherit; font-size: 0.82rem; color: var(--text-primary);
|
|
|
|
|
|
background: transparent; border: 0; border-radius: var(--radius-sm);
|
|
|
|
|
|
padding: 0.55rem 0.65rem; cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
.menu-item:hover { background: var(--neutral-bg); }
|
|
|
|
|
|
.menu-meta { font-size: 0.75rem; color: var(--text-sec); }
|
|
|
|
|
|
|
|
|
|
|
|
/* ── De dialoog ─────────────────────────────────────────────────────────── */
|
|
|
|
|
|
.sheet {
|
|
|
|
|
|
/* Midden op het scherm, in beide richtingen. Niet op de standaardopmaak van de
|
|
|
|
|
|
browser vertrouwen: die zet een dialog absoluut ten opzichte van zijn
|
|
|
|
|
|
dichtstbijzijnde geplaatste voorouder, en deze staat in het raster van de
|
|
|
|
|
|
pagina. */
|
|
|
|
|
|
position: fixed;
|
|
|
|
|
|
inset: 0;
|
|
|
|
|
|
margin: auto;
|
|
|
|
|
|
grid-column: 1 / -1;
|
|
|
|
|
|
width: min(46rem, calc(100vw - 2rem));
|
|
|
|
|
|
max-height: min(44rem, calc(100vh - 4rem));
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
color: var(--text-primary);
|
2026-08-28 11:22:32 +02:00
|
|
|
|
background: var(--bg-elevated);
|
|
|
|
|
|
border: 1px solid var(--border);
|
|
|
|
|
|
border-radius: var(--radius-lg);
|
2026-08-30 12:26:56 +02:00
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
.sheet::backdrop { background: rgba(0,0,0,0.55); }
|
|
|
|
|
|
.sheet-head {
|
|
|
|
|
|
display: flex; align-items: center; justify-content: space-between; gap: 1rem;
|
|
|
|
|
|
padding: 1.3rem 1.5rem; border-bottom: 1px solid var(--border);
|
|
|
|
|
|
}
|
|
|
|
|
|
/* Kaal, met een accent bij hover. Niet rood: er gaat niets kapot van sluiten. */
|
|
|
|
|
|
.sheet-close {
|
|
|
|
|
|
display: inline-flex; align-items: center; justify-content: center;
|
|
|
|
|
|
flex-shrink: 0; width: 2rem; height: 2rem;
|
|
|
|
|
|
font-family: inherit; font-size: 1.5rem; line-height: 1;
|
|
|
|
|
|
color: var(--text-sec); background: transparent; border: 0;
|
|
|
|
|
|
border-radius: var(--radius-sm); cursor: pointer;
|
|
|
|
|
|
transition: color 0.2s var(--ease-out);
|
|
|
|
|
|
}
|
|
|
|
|
|
.sheet-close:hover { color: var(--accent); }
|
|
|
|
|
|
.sheet-close:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
|
|
|
|
/* Alleen dit deel scrollt, zodat de sluitknop in beeld blijft. */
|
|
|
|
|
|
.sheet-body {
|
|
|
|
|
|
padding: 1.2rem 1.5rem 1.5rem;
|
|
|
|
|
|
overflow: auto;
|
|
|
|
|
|
max-height: calc(min(44rem, 100vh - 4rem) - 5rem);
|
|
|
|
|
|
scrollbar-width: thin;
|
|
|
|
|
|
scrollbar-color: var(--scrollbar) transparent;
|
|
|
|
|
|
}
|
|
|
|
|
|
.sheet-body h3 {
|
|
|
|
|
|
font-size: 0.78rem; font-weight: 600; letter-spacing: 0.08em;
|
|
|
|
|
|
text-transform: uppercase; color: var(--text-sec);
|
|
|
|
|
|
margin: 1.6rem 0 0.5rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.sheet-body h3:first-child { margin-top: 0; }
|
|
|
|
|
|
.sheet-body p { font-size: 0.85rem; line-height: 1.65; color: var(--text-sec); margin-bottom: 0.7rem; }
|
|
|
|
|
|
.sheet-body p strong { color: var(--text-primary); font-weight: 600; }
|
|
|
|
|
|
.sheet-body .t-mono { overflow-wrap: anywhere; }
|
|
|
|
|
|
|
|
|
|
|
|
.hidden { display: none !important; }
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 700px) {
|
|
|
|
|
|
.list-item { flex-wrap: wrap; align-items: flex-start; gap: 0.5rem; }
|
|
|
|
|
|
.list-item > * { flex-basis: 100%; min-width: 0; }
|
|
|
|
|
|
.owner-actions { margin-left: auto; }
|
2026-08-28 11:22:32 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-30 12:26:56 +02:00
|
|
|
|
@media (max-width: 560px) {
|
|
|
|
|
|
body { padding: 1.25rem 0.9rem 2rem; }
|
|
|
|
|
|
.card { --padding: 1.3rem; padding: var(--padding); --radius: var(--radius-lg); border-radius: var(--radius); }
|
|
|
|
|
|
.t-h1 { font-size: 1.5rem; }
|
2026-08-28 11:22:32 +02:00
|
|
|
|
}
|
|
|
|
|
|
</style>
|
|
|
|
|
|
</head>
|
|
|
|
|
|
<body>
|
|
|
|
|
|
<div class="wrap">
|
|
|
|
|
|
|
2026-08-30 12:26:56 +02:00
|
|
|
|
<header class="head">
|
|
|
|
|
|
<div class="mark" aria-hidden="true">
|
|
|
|
|
|
<img src="icon.png" alt="" id="mark-icon"
|
|
|
|
|
|
onerror="this.remove(); document.getElementById('mark-fallback').classList.remove('hidden');">
|
|
|
|
|
|
<svg id="mark-fallback" class="hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
|
|
|
|
|
<path d="M4 7h16"></path><path d="M4 12h16"></path><path d="M4 17h16"></path>
|
|
|
|
|
|
<circle cx="8" cy="7" r="1.6" fill="currentColor" stroke="none"></circle>
|
|
|
|
|
|
<circle cx="16" cy="12" r="1.6" fill="currentColor" stroke="none"></circle>
|
|
|
|
|
|
<circle cx="8" cy="17" r="1.6" fill="currentColor" stroke="none"></circle>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="head-text">
|
|
|
|
|
|
<h1 class="t-h1">Evolu Relay</h1>
|
|
|
|
|
|
<!-- Woordelijk dezelfde regel als de tagline in umbrel-app.yml, en dat is
|
|
|
|
|
|
het punt: één app, één regel. -->
|
2026-09-08 16:36:30 +02:00
|
|
|
|
<p class="t-body-sm">Encrypted sync and backup for your local-first apps <span class="head-version" id="head-version"></span></p>
|
2026-08-30 12:26:56 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
<!-- Het menu rechtsboven, met details/summary en niet met eigen JavaScript:
|
|
|
|
|
|
dan werkt openen en sluiten met het toetsenbord zonder dat wij daar iets
|
|
|
|
|
|
voor doen. Wat er wél bij moet is sluiten op Escape en bij een klik
|
|
|
|
|
|
ernaast; dat staat in het script. -->
|
|
|
|
|
|
<details class="menu" id="menu">
|
|
|
|
|
|
<!-- Drie punten en geen hamburger: dat is wat umbrelOS bij zijn eigen apps
|
|
|
|
|
|
toont, en een pagina die in dat dashboard hangt hoort daar niet van af te
|
|
|
|
|
|
wijken. Middelste punten (U+00B7) en geen gewone punten: die staan op de
|
|
|
|
|
|
basislijn en zakken dus naar de onderrand van de knop. -->
|
|
|
|
|
|
<summary class="menu-button" title="Menu" aria-label="Menu">···</summary>
|
|
|
|
|
|
<div class="menu-panel">
|
|
|
|
|
|
<button class="menu-item" id="menu-about" type="button">
|
|
|
|
|
|
<span>About this app…</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<!-- De regel toont de huidige stand en niet de handeling: als menuregel
|
|
|
|
|
|
met een waarde erachter leest het als een bewering over hoe het nú
|
|
|
|
|
|
staat. -->
|
|
|
|
|
|
<button class="menu-item" id="menu-theme" type="button">
|
|
|
|
|
|
<span>Appearance</span>
|
|
|
|
|
|
<span class="menu-meta" id="theme-label">Dark</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</details>
|
2026-08-28 11:22:32 +02:00
|
|
|
|
</header>
|
|
|
|
|
|
|
2026-08-30 12:26:56 +02:00
|
|
|
|
<!-- Meldingen. Standaard verborgen; het script bepaalt welke past. -->
|
|
|
|
|
|
<div id="alert-agent" class="alert alert-error hidden">
|
|
|
|
|
|
<span class="alert-icon">
|
|
|
|
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 3 2 20h20z"></path><path d="M12 9v4"></path><path d="M12 16.5v.01"></path></svg>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span id="alert-agent-body">The status page cannot reach its helper inside the app.</span>
|
|
|
|
|
|
</div>
|
2026-08-28 11:22:32 +02:00
|
|
|
|
|
2026-08-30 12:26:56 +02:00
|
|
|
|
<div id="alert-owners" class="alert alert-error hidden">
|
|
|
|
|
|
<span class="alert-icon">
|
|
|
|
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 3 2 20h20z"></path><path d="M12 9v4"></path><path d="M12 16.5v.01"></path></svg>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span id="alert-owners-body"></span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div id="alert-notice" class="alert alert-neutral hidden">
|
|
|
|
|
|
<span class="alert-icon">
|
|
|
|
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"></circle><path d="M12 8v5"></path><path d="M12 16.5v.01"></path></svg>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span id="alert-notice-body"></span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="grid">
|
|
|
|
|
|
|
|
|
|
|
|
<!-- ── De bovenste rij ────────────────────────────────────────────────── -->
|
|
|
|
|
|
<div class="row1">
|
|
|
|
|
|
<div class="card">
|
|
|
|
|
|
<div class="card-head"><h2 class="t-h3">Stored data</h2></div>
|
|
|
|
|
|
<div class="stat-body">
|
|
|
|
|
|
<div class="stat-val unknown" id="db-size">unknown</div>
|
|
|
|
|
|
<div class="stat-sub" id="db-sub"></div>
|
|
|
|
|
|
</div>
|
2026-08-28 11:22:32 +02:00
|
|
|
|
</div>
|
2026-08-30 12:26:56 +02:00
|
|
|
|
|
|
|
|
|
|
<div class="card">
|
|
|
|
|
|
<div class="card-head"><h2 class="t-h3">Last write</h2></div>
|
|
|
|
|
|
<div class="stat-body">
|
|
|
|
|
|
<div class="stat-val text unknown" id="db-modified">unknown</div>
|
|
|
|
|
|
<div class="stat-sub" id="db-modified-sub"></div>
|
|
|
|
|
|
</div>
|
2026-08-28 11:22:32 +02:00
|
|
|
|
</div>
|
2026-08-30 12:26:56 +02:00
|
|
|
|
|
2026-09-09 15:45:55 +02:00
|
|
|
|
<!-- Hoeveel eigenaars er op een beslissing wachten.
|
|
|
|
|
|
Tot 0.6.0 stond hier het tijdvenster voor nieuwe eigenaars, met een
|
|
|
|
|
|
knop om het twee minuten open te zetten. Dat is eruit in 0.7.0: er is
|
|
|
|
|
|
nog één weg naar binnen, en dat is de wachtlijst hieronder. Twee wegen
|
|
|
|
|
|
was dubbelop, en het venster was de zwakste van de twee omdat het
|
|
|
|
|
|
iederéén toeliet die er toevallig in verbond. -->
|
2026-08-30 12:26:56 +02:00
|
|
|
|
<div class="card">
|
2026-09-09 15:45:55 +02:00
|
|
|
|
<div class="card-head"><h2 class="t-h3">Waiting</h2></div>
|
2026-08-30 12:26:56 +02:00
|
|
|
|
<div class="stat-body">
|
2026-09-09 15:45:55 +02:00
|
|
|
|
<div class="stat-val text unknown" id="waiting-state">unknown</div>
|
|
|
|
|
|
<div class="stat-sub" id="waiting-sub"></div>
|
2026-08-30 12:26:56 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-08-31 12:14:37 +02:00
|
|
|
|
<!-- De statuswidget staat RECHTS en niet links (keuze gebruiker,
|
|
|
|
|
|
31-08-2026), gelijk aan Electrum Gate. Twee redenen die samenvallen:
|
|
|
|
|
|
de twee pagina's horen naast elkaar te lezen als één store, en dit is
|
|
|
|
|
|
de widget die als enige begrijpelijk is zonder buur, dus hij is de
|
|
|
|
|
|
juiste om alleen op een regel te belanden als het scherm versmalt.
|
|
|
|
|
|
|
|
|
|
|
|
De kop heet "Status" en niet "Relay": op een pagina die over de relay
|
|
|
|
|
|
gáát zegt "Relay" niets. De id's blijven relay-state en relay-sub, want
|
|
|
|
|
|
dat is nog steeds waar de waarde vandaan komt en hernoemen zou alleen
|
|
|
|
|
|
het JavaScript raken zonder dat iemand er iets aan heeft. -->
|
|
|
|
|
|
<div class="card">
|
|
|
|
|
|
<div class="card-head"><h2 class="t-h3">Status</h2></div>
|
|
|
|
|
|
<div class="stat-body">
|
|
|
|
|
|
<div class="stat-val text unknown" id="relay-state">unknown</div>
|
|
|
|
|
|
<div class="stat-sub" id="relay-sub"></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-08-30 12:26:56 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- ── De twee lijsten ───────────────────────────────────────────────── -->
|
|
|
|
|
|
<div class="card col-6">
|
2026-09-10 09:54:22 +02:00
|
|
|
|
<div class="card-head"><h2 class="t-h3">Accepted</h2></div>
|
2026-08-30 12:26:56 +02:00
|
|
|
|
<div id="allowed"></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-09-09 15:45:55 +02:00
|
|
|
|
<!-- Wachtend en geblokkeerd in één kader, op verzoek van de gebruiker
|
2026-08-30 12:26:56 +02:00
|
|
|
|
(30-08-2026). Het waren twee kaders, en het onderscheid ertussen is voor
|
|
|
|
|
|
de gebruiker geen verschil in wat hij ermee doet: allebei zijn ze
|
|
|
|
|
|
buitengesloten en allebei kun je ze toelaten. Dat het bij de een een
|
|
|
|
|
|
eerdere keuze van jou was en bij de ander een poging van buiten, staat als
|
2026-09-09 15:45:55 +02:00
|
|
|
|
badge op de regel.
|
|
|
|
|
|
|
|
|
|
|
|
"Waiting list" en niet "Refused owners" sinds 0.7.0, op voorstel van de
|
|
|
|
|
|
gebruiker. Dat is wat het is: kandidaten die op een beslissing wachten, en
|
|
|
|
|
|
sinds het tijdvenster eruit is, is dit de enige weg naar binnen. Het veld
|
|
|
|
|
|
heet in owners.json nog `rejected`; hernoemen zou een migratie zijn voor
|
|
|
|
|
|
een woord dat niemand ziet. -->
|
2026-08-30 12:26:56 +02:00
|
|
|
|
<div class="card col-6">
|
2026-09-10 09:54:22 +02:00
|
|
|
|
<div class="card-head"><h2 class="t-h3">Waiting</h2></div>
|
2026-08-30 12:26:56 +02:00
|
|
|
|
<div id="refused"></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- ── Het adres ─────────────────────────────────────────────────────── -->
|
|
|
|
|
|
<!-- Onderaan en niet bovenaan: dit is wat je één keer overneemt in je app, en
|
|
|
|
|
|
de rij bovenin is wat je nakomt. -->
|
|
|
|
|
|
<div class="card col-full">
|
|
|
|
|
|
<div class="card-head"><h2 class="t-h3">Relay address</h2></div>
|
2026-09-10 09:54:22 +02:00
|
|
|
|
<!-- Hieronder stond in 0.7.0 een regel over de vorm achter een reverse
|
|
|
|
|
|
proxy (wss://, zonder poort). Eruit op verzoek van de gebruiker,
|
|
|
|
|
|
10-09-2026: dit kader is het adres en niets anders. Wat er over
|
|
|
|
|
|
bereikbaarheid van buiten te zeggen valt, staat in "About this app". -->
|
2026-08-30 12:26:56 +02:00
|
|
|
|
<div class="url-box" id="relay-url">unknown</div>
|
2026-08-28 11:22:32 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-08-30 12:26:56 +02:00
|
|
|
|
<!-- De dialoog "About this app". Hier staat wat eerder als uitleg in de kaders
|
|
|
|
|
|
stond, plus de tekst uit umbrel-app.yml. Met het dialog-element en niet met
|
|
|
|
|
|
een eigen overlay: focus, Escape en het afschermen van de pagina erachter
|
|
|
|
|
|
zitten erin. -->
|
|
|
|
|
|
<dialog id="about-dialog" class="sheet">
|
|
|
|
|
|
<form method="dialog" class="sheet-head">
|
|
|
|
|
|
<h2 class="t-h3">About this app</h2>
|
|
|
|
|
|
<!-- Een kruisje en niet het woord "Close". Het maalteken U+00D7 en geen
|
|
|
|
|
|
letter x: dat is het teken dat hiervoor bestaat en het staat symmetrisch.
|
|
|
|
|
|
Het aria-label houdt "Close" aan. -->
|
|
|
|
|
|
<button class="sheet-close" value="close" aria-label="Close" title="Close">×</button>
|
|
|
|
|
|
</form>
|
2026-08-28 11:22:32 +02:00
|
|
|
|
|
2026-08-30 12:26:56 +02:00
|
|
|
|
<div class="sheet-body">
|
|
|
|
|
|
<h3>What it is</h3>
|
|
|
|
|
|
<p>
|
|
|
|
|
|
Local-first apps keep your data on your own device and work whether or not you have a
|
|
|
|
|
|
connection. A relay is what lets a second device catch up: it holds the encrypted changes
|
|
|
|
|
|
until your other devices come online. This app runs that relay on your Umbrel, so the copy
|
|
|
|
|
|
in the middle sits on hardware you own.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p>
|
|
|
|
|
|
It is the relay from the <strong>Evolu</strong> project, taken straight from the package they
|
|
|
|
|
|
publish and not reimplemented here. Any app built on Evolu can use it.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
|
|
<h3>What the relay can see</h3>
|
|
|
|
|
|
<p>
|
|
|
|
|
|
Your data is encrypted on your device before it leaves. The relay holds encrypted blobs,
|
|
|
|
|
|
padded so their size says nothing, and never has the key. What it can see is an owner id,
|
|
|
|
|
|
which identifies data without identifying you, along with timestamps and the addresses that
|
|
|
|
|
|
connect.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p>
|
|
|
|
|
|
Self-hosting does not make the data more secret than it already is. What it changes is who
|
|
|
|
|
|
keeps the encrypted copy, who can see that you are syncing at all, and whether anyone can
|
|
|
|
|
|
pull the service out from under you.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
|
|
<h3>Pointing an app at it</h3>
|
|
|
|
|
|
<p>
|
|
|
|
|
|
Set the address at the bottom of this page as the sync address in your app. Some apps only
|
|
|
|
|
|
offer the field in their developer or advanced settings. The address has to start with
|
|
|
|
|
|
<strong>http</strong> or <strong>https</strong>, not ws, even though the connection itself is
|
|
|
|
|
|
a websocket: the client works that out on its own.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p>
|
|
|
|
|
|
Away from home you will need a way in, such as Tailscale or a reverse proxy with your own
|
|
|
|
|
|
domain.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
|
|
<h3>Who may use it</h3>
|
|
|
|
|
|
<p>
|
|
|
|
|
|
What this app adds is the part a self-hosted relay needs and a public one does not: control
|
2026-09-09 15:45:55 +02:00
|
|
|
|
over who may use it. Nobody gets in by default. An owner that connects for the first time is
|
|
|
|
|
|
turned away and lands on the <strong>waiting list</strong> with its owner id, and you decide
|
|
|
|
|
|
there whether it may stay. One app can use more than one owner id, so expect more than one
|
|
|
|
|
|
row per device.
|
2026-08-30 12:26:56 +02:00
|
|
|
|
</p>
|
|
|
|
|
|
<p>
|
2026-09-09 15:45:55 +02:00
|
|
|
|
An owner on the waiting list syncs nothing at all, and nothing of it is stored: the relay
|
|
|
|
|
|
turns the connection away before it starts. Its device keeps everything locally and retries,
|
|
|
|
|
|
so allowing it later still brings the whole history across, and you do not have to be quick.
|
2026-08-30 12:26:56 +02:00
|
|
|
|
</p>
|
|
|
|
|
|
<p>
|
|
|
|
|
|
Owner ids are long and meaningless on purpose. Hover a row and use <strong>Label</strong> to
|
|
|
|
|
|
give one a name of your own; the name is kept on the Umbrel and is only ever shown here.
|
|
|
|
|
|
</p>
|
2026-08-28 11:22:32 +02:00
|
|
|
|
</div>
|
2026-08-30 12:26:56 +02:00
|
|
|
|
</dialog>
|
2026-08-28 11:22:32 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<script>
|
|
|
|
|
|
(function () {
|
|
|
|
|
|
'use strict';
|
|
|
|
|
|
|
|
|
|
|
|
var root = document.documentElement;
|
2026-08-30 12:26:56 +02:00
|
|
|
|
|
|
|
|
|
|
// Eén opdracht tegelijk: de agent weigert een tweede zolang de eerste nog in de
|
|
|
|
|
|
// postbus ligt, en twee knoppen die tegelijk indrukbaar zijn zouden dat als een
|
|
|
|
|
|
// fout tonen terwijl er niets mis is.
|
2026-08-28 11:22:32 +02:00
|
|
|
|
var busy = false;
|
|
|
|
|
|
|
2026-08-30 12:26:56 +02:00
|
|
|
|
// Het id van de eigenaar die op dit moment een label krijgt, of null. Zolang dit
|
|
|
|
|
|
// gezet is, laat de ronde de lijsten met rust: opnieuw opbouwen zou het veld
|
|
|
|
|
|
// waarin je typt weghalen.
|
|
|
|
|
|
var editing = null;
|
|
|
|
|
|
|
2026-08-30 13:17:26 +02:00
|
|
|
|
// Staat er een weigering in de melding bovenaan?
|
|
|
|
|
|
//
|
|
|
|
|
|
// Waar dit voor is: de ronde ruimt die melding zelf op zodra hij niet meer waar
|
|
|
|
|
|
// is, en dat is precies goed voor "de opdracht is verstuurd". Maar een weigering
|
|
|
|
|
|
// mag niet binnen vijf seconden verdwijnen: dan is de enige uitleg van wat er
|
|
|
|
|
|
// misging weg voordat je hem gelezen hebt. Vandaar deze vlag; de ronde laat de
|
|
|
|
|
|
// melding met rust zolang hij aanstaat.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Als vlag en niet door naar de klasse van het element te kijken. Dat laatste
|
|
|
|
|
|
// deed de vorige versie van deze pagina, en dan hangt het gedrag aan een naam in
|
|
|
|
|
|
// een class-attribuut die er ook om opmaakredenen kan staan.
|
|
|
|
|
|
var refusal = false;
|
|
|
|
|
|
|
2026-08-30 12:26:56 +02:00
|
|
|
|
// ── Hulpjes ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
function el(id) { return document.getElementById(id); }
|
|
|
|
|
|
|
|
|
|
|
|
// Zet een waarde, of laat het veld zichtbaar op onbekend staan. Nooit een lege
|
|
|
|
|
|
// string: een leeg veld leest als "in orde" en dat is het niet.
|
|
|
|
|
|
function put(id, value) {
|
|
|
|
|
|
var node = el(id);
|
|
|
|
|
|
if (!node) { return; }
|
|
|
|
|
|
if (value === null || value === undefined || value === '') {
|
|
|
|
|
|
node.textContent = 'unknown';
|
|
|
|
|
|
node.classList.add('unknown');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
node.textContent = String(value);
|
|
|
|
|
|
node.classList.remove('unknown');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-30 13:17:26 +02:00
|
|
|
|
function show(id, text, kind) {
|
2026-08-30 12:26:56 +02:00
|
|
|
|
var node = el(id);
|
|
|
|
|
|
var body = el(id + '-body');
|
|
|
|
|
|
if (body && text) { body.textContent = text; }
|
2026-08-30 13:17:26 +02:00
|
|
|
|
if (kind) {
|
|
|
|
|
|
node.classList.remove('alert-neutral', 'alert-warn', 'alert-error');
|
|
|
|
|
|
node.classList.add(kind);
|
|
|
|
|
|
}
|
2026-08-30 12:26:56 +02:00
|
|
|
|
node.classList.remove('hidden');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function hide(id) { el(id).classList.add('hidden'); }
|
|
|
|
|
|
|
|
|
|
|
|
function formatBytes(bytes) {
|
|
|
|
|
|
if (typeof bytes !== 'number') { return null; }
|
|
|
|
|
|
if (bytes < 1024) { return bytes + ' B'; }
|
|
|
|
|
|
if (bytes < 1024 * 1024) { return (bytes / 1024).toFixed(1) + ' kB'; }
|
|
|
|
|
|
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatTime(value) {
|
|
|
|
|
|
if (typeof value !== 'string') { return null; }
|
|
|
|
|
|
var when = new Date(value);
|
|
|
|
|
|
if (isNaN(when.getTime())) { return null; }
|
|
|
|
|
|
return when.toLocaleString();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Hoe lang geleden, in woorden. Op de widget is "3 minutes ago" bruikbaarder dan
|
|
|
|
|
|
// een tijdstip; het tijdstip zelf staat op de onderregel.
|
|
|
|
|
|
function formatAgo(value) {
|
|
|
|
|
|
if (typeof value !== 'string') { return null; }
|
|
|
|
|
|
var when = new Date(value);
|
|
|
|
|
|
if (isNaN(when.getTime())) { return null; }
|
|
|
|
|
|
var seconds = Math.round((Date.now() - when.getTime()) / 1000);
|
|
|
|
|
|
if (seconds < 0) { seconds = 0; }
|
|
|
|
|
|
if (seconds < 60) { return 'just now'; }
|
|
|
|
|
|
var minutes = Math.round(seconds / 60);
|
|
|
|
|
|
if (minutes < 60) { return minutes + (minutes === 1 ? ' minute ago' : ' minutes ago'); }
|
|
|
|
|
|
var hours = Math.round(minutes / 60);
|
|
|
|
|
|
if (hours < 48) { return hours + (hours === 1 ? ' hour ago' : ' hours ago'); }
|
|
|
|
|
|
return Math.round(hours / 24) + ' days ago';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function shorten(ownerId) {
|
|
|
|
|
|
if (typeof ownerId !== 'string') { return 'unknown'; }
|
|
|
|
|
|
if (ownerId.length <= 26) { return ownerId; }
|
|
|
|
|
|
return ownerId.slice(0, 14) + '…' + ownerId.slice(-8);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Thema ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
var saved = null;
|
|
|
|
|
|
try { saved = localStorage.getItem('er-theme'); } catch (e) { saved = null; }
|
|
|
|
|
|
if (!saved && window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) {
|
|
|
|
|
|
saved = 'light';
|
|
|
|
|
|
}
|
|
|
|
|
|
applyTheme(saved === 'light' ? 'light' : 'dark');
|
|
|
|
|
|
|
|
|
|
|
|
function applyTheme(theme) {
|
2026-08-28 11:22:32 +02:00
|
|
|
|
root.setAttribute('data-theme', theme);
|
2026-08-30 12:26:56 +02:00
|
|
|
|
el('theme-label').textContent = theme === 'dark' ? 'Dark' : 'Light';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
el('menu-theme').addEventListener('click', function () {
|
|
|
|
|
|
var next = root.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
|
|
|
|
|
|
applyTheme(next);
|
|
|
|
|
|
try { localStorage.setItem('er-theme', next); } catch (e) { /* privémodus; niet erg */ }
|
2026-08-28 11:22:32 +02:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-30 12:26:56 +02:00
|
|
|
|
// ── Het menu ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
// details opent en sluit zichzelf, ook met het toetsenbord. Wat het niet doet is
|
|
|
|
|
|
// sluiten bij een klik ernaast of op Escape, en dat is bij een menu wel de
|
|
|
|
|
|
// verwachting. Vandaar deze twee, en niet meer dan deze twee.
|
|
|
|
|
|
var menu = el('menu');
|
2026-08-28 11:22:32 +02:00
|
|
|
|
|
2026-08-30 12:26:56 +02:00
|
|
|
|
function closeMenu() { menu.removeAttribute('open'); }
|
2026-08-28 11:22:32 +02:00
|
|
|
|
|
2026-08-30 12:26:56 +02:00
|
|
|
|
document.addEventListener('click', function (e) {
|
|
|
|
|
|
if (menu.hasAttribute('open') && !menu.contains(e.target)) { closeMenu(); }
|
|
|
|
|
|
});
|
|
|
|
|
|
document.addEventListener('keydown', function (e) {
|
|
|
|
|
|
if (e.key === 'Escape' && menu.hasAttribute('open')) { closeMenu(); }
|
|
|
|
|
|
});
|
2026-08-28 11:22:32 +02:00
|
|
|
|
|
2026-08-30 12:26:56 +02:00
|
|
|
|
// ── De dialoog ────────────────────────────────────────────────────────────
|
|
|
|
|
|
var sheet = el('about-dialog');
|
2026-08-28 11:22:32 +02:00
|
|
|
|
|
2026-08-30 12:26:56 +02:00
|
|
|
|
el('menu-about').addEventListener('click', function () {
|
|
|
|
|
|
closeMenu();
|
|
|
|
|
|
if (typeof sheet.showModal === 'function') {
|
|
|
|
|
|
sheet.showModal();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Zonder showModal geen overlay en geen focusval, maar wel een zichtbare
|
|
|
|
|
|
// dialoog. Beter dan een menuregel die niets doet.
|
|
|
|
|
|
sheet.setAttribute('open', '');
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-08-28 11:22:32 +02:00
|
|
|
|
|
2026-08-30 12:26:56 +02:00
|
|
|
|
// ── Opdrachten ────────────────────────────────────────────────────────────
|
|
|
|
|
|
function post(path, body, whenDone) {
|
|
|
|
|
|
return fetch(path, {
|
2026-08-28 11:22:32 +02:00
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
2026-08-30 12:26:56 +02:00
|
|
|
|
body: JSON.stringify(body)
|
2026-08-28 11:22:32 +02:00
|
|
|
|
}).then(function (response) {
|
2026-08-30 12:26:56 +02:00
|
|
|
|
return response.json().then(function (parsed) {
|
|
|
|
|
|
return { ok: response.ok, body: parsed };
|
|
|
|
|
|
}, function () {
|
|
|
|
|
|
return { ok: response.ok, body: {} };
|
|
|
|
|
|
});
|
2026-08-28 11:22:32 +02:00
|
|
|
|
}).then(function (result) {
|
|
|
|
|
|
if (!result.ok) {
|
2026-08-30 13:17:26 +02:00
|
|
|
|
refusal = true;
|
|
|
|
|
|
show('alert-notice', 'The app refused that: ' + (result.body.error || 'no reason given'),
|
|
|
|
|
|
'alert-error');
|
2026-08-30 12:26:56 +02:00
|
|
|
|
} else if (whenDone) {
|
|
|
|
|
|
whenDone();
|
2026-08-28 11:22:32 +02:00
|
|
|
|
}
|
2026-08-30 12:26:56 +02:00
|
|
|
|
}, function () {
|
2026-08-30 13:17:26 +02:00
|
|
|
|
refusal = true;
|
|
|
|
|
|
show('alert-notice', 'Could not reach the app.', 'alert-error');
|
2026-08-30 12:26:56 +02:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-30 13:17:26 +02:00
|
|
|
|
// Elke opdracht begint met een schone lei bovenaan: een weigering van de vorige
|
|
|
|
|
|
// poging hoort niet boven het resultaat van deze te blijven staan.
|
|
|
|
|
|
function clearNotice() {
|
|
|
|
|
|
refusal = false;
|
|
|
|
|
|
hide('alert-notice');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-09 15:45:55 +02:00
|
|
|
|
// Bevriezen in plaats van een melding bovenaan.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Tot 0.6.0 verscheen bij élke klik "Sent. The relay applies changes within a
|
|
|
|
|
|
// few seconds." in de balk bovenaan. Die balk staat in de gewone stroom van de
|
|
|
|
|
|
// pagina, dus alles eronder schoof omlaag en bij het verdwijnen weer omhoog.
|
|
|
|
|
|
// Gemeld door de gebruiker op 09-09-2026, en terecht: dat is een melding over
|
|
|
|
|
|
// het verzendmechanisme en niet over wat jij deed. Wat je deed, zie je aan de
|
|
|
|
|
|
// lijsten zodra de relay de opdracht heeft opgepakt.
|
|
|
|
|
|
//
|
|
|
|
|
|
// In plaats daarvan gaan de knoppen even op slot. Dat verschuift niets, het
|
|
|
|
|
|
// toont dat er iets loopt, en het voorkomt een tweede klik terwijl de eerste
|
|
|
|
|
|
// nog onderweg is. De foutmeldingen blijven wél in de balk staan: die zeggen
|
|
|
|
|
|
// iets wat je nergens anders ziet, en die hóórt de pagina in de weg te leggen.
|
|
|
|
|
|
function setFrozen(frozen) {
|
|
|
|
|
|
document.body.classList.toggle('is-busy', frozen);
|
|
|
|
|
|
var knoppen = document.querySelectorAll('.list-item button');
|
|
|
|
|
|
for (var i = 0; i < knoppen.length; i += 1) { knoppen[i].disabled = frozen; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-30 12:26:56 +02:00
|
|
|
|
function send(command) {
|
|
|
|
|
|
if (busy) { return; }
|
|
|
|
|
|
busy = true;
|
2026-09-09 15:45:55 +02:00
|
|
|
|
setFrozen(true);
|
2026-08-30 13:17:26 +02:00
|
|
|
|
clearNotice();
|
2026-09-09 15:45:55 +02:00
|
|
|
|
post('api/command', command).then(function () {
|
|
|
|
|
|
// Verversen en pas daarná ontdooien: de ronde bouwt de lijsten opnieuw op,
|
|
|
|
|
|
// en tot dat gebeurd is staat de oude stand er nog. Ontdooien vóór de
|
|
|
|
|
|
// ronde zou een klikbare knop opleveren die nog bij de vorige stand hoort.
|
|
|
|
|
|
window.setTimeout(function () {
|
|
|
|
|
|
refresh();
|
|
|
|
|
|
busy = false;
|
|
|
|
|
|
setFrozen(false);
|
|
|
|
|
|
}, 2500);
|
2026-08-28 11:22:32 +02:00
|
|
|
|
});
|
2026-08-30 12:26:56 +02:00
|
|
|
|
}
|
2026-08-28 11:22:32 +02:00
|
|
|
|
|
2026-08-30 12:26:56 +02:00
|
|
|
|
function sendLabel(ownerId, label) {
|
2026-08-30 13:17:26 +02:00
|
|
|
|
clearNotice();
|
2026-08-30 12:26:56 +02:00
|
|
|
|
// Geen busy-slot hieromheen, en dat is geen vergetelheid: een label gaat niet
|
|
|
|
|
|
// via de postbus van het relay-proces maar rechtstreeks naar een eigen bestand
|
|
|
|
|
|
// van de agent. Er is dus niets waarop het hoeft te wachten.
|
|
|
|
|
|
post('api/label', { ownerId: ownerId, label: label }).then(refresh);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Een regel in een lijst ────────────────────────────────────────────────
|
|
|
|
|
|
// `kind` is 'allowed', 'blocked' of 'rejected'. Dat bepaalt de badge en welke
|
|
|
|
|
|
// knoppen er staan; de rest van de regel is voor alle drie hetzelfde.
|
|
|
|
|
|
function ownerRow(entry, kind) {
|
2026-08-28 11:22:32 +02:00
|
|
|
|
var row = document.createElement('div');
|
2026-08-30 12:26:56 +02:00
|
|
|
|
row.className = 'list-item';
|
2026-08-28 11:22:32 +02:00
|
|
|
|
|
2026-08-28 12:08:19 +02:00
|
|
|
|
var who = document.createElement('div');
|
2026-08-30 12:26:56 +02:00
|
|
|
|
who.className = 'owner-who';
|
2026-08-28 12:08:19 +02:00
|
|
|
|
|
2026-08-30 12:26:56 +02:00
|
|
|
|
var title = document.createElement('div');
|
|
|
|
|
|
var sub = document.createElement('div');
|
2026-08-28 11:22:32 +02:00
|
|
|
|
|
2026-08-30 12:26:56 +02:00
|
|
|
|
// Met een label staat het label bovenaan en het id eronder; zonder label is
|
|
|
|
|
|
// het id de titel. Zo staat het meest zeggende altijd op de eerste regel.
|
2026-09-10 09:54:22 +02:00
|
|
|
|
//
|
|
|
|
|
|
// ALTIJD TWEE REGELS, en dat is de kern van deze opzet (verzoek gebruiker,
|
|
|
|
|
|
// 10-09-2026). Tot 0.7.0 kwam er een derde regel bij zodra er een label
|
|
|
|
|
|
// stond, want de tweede was dan het id; het gevolg was dat een rij hoger
|
|
|
|
|
|
// werd op het moment dat je hem labelde en dat de rijen in de twee lijsten
|
|
|
|
|
|
// niet meer gelijk stonden. Nu deelt het id de onderregel met de tijden. Een
|
|
|
|
|
|
// vaste hoogte in CSS zou hetzelfde lijken te doen, maar dan staat er witruimte
|
|
|
|
|
|
// onder een rij zonder label en dat leest als een weergavefout.
|
2026-08-30 12:26:56 +02:00
|
|
|
|
if (entry.label) {
|
|
|
|
|
|
title.className = 'list-item-title';
|
|
|
|
|
|
title.textContent = entry.label;
|
2026-09-10 09:54:22 +02:00
|
|
|
|
title.title = entry.label;
|
|
|
|
|
|
sub.className = 'list-item-sub';
|
|
|
|
|
|
// Het id houdt zijn eigen letter, ook op een gedeelde regel: het is het
|
|
|
|
|
|
// enige hier dat je teken voor teken vergelijkt.
|
|
|
|
|
|
var idDeel = document.createElement('span');
|
|
|
|
|
|
idDeel.className = 'mono';
|
|
|
|
|
|
idDeel.textContent = shorten(entry.id);
|
|
|
|
|
|
idDeel.title = entry.id;
|
|
|
|
|
|
sub.appendChild(idDeel);
|
|
|
|
|
|
var rest = describe(entry, kind);
|
|
|
|
|
|
if (rest) {
|
|
|
|
|
|
var restDeel = document.createElement('span');
|
|
|
|
|
|
restDeel.textContent = ' · ' + rest;
|
|
|
|
|
|
restDeel.title = describeExact(entry);
|
|
|
|
|
|
sub.appendChild(restDeel);
|
|
|
|
|
|
}
|
2026-08-30 12:26:56 +02:00
|
|
|
|
} else {
|
|
|
|
|
|
title.className = 'list-item-title mono';
|
|
|
|
|
|
title.textContent = shorten(entry.id);
|
|
|
|
|
|
title.title = entry.id;
|
|
|
|
|
|
sub.className = 'list-item-sub';
|
|
|
|
|
|
sub.textContent = describe(entry, kind);
|
|
|
|
|
|
sub.title = describeExact(entry);
|
|
|
|
|
|
}
|
|
|
|
|
|
who.appendChild(title);
|
|
|
|
|
|
who.appendChild(sub);
|
|
|
|
|
|
|
2026-08-28 12:08:19 +02:00
|
|
|
|
row.appendChild(who);
|
2026-08-28 11:22:32 +02:00
|
|
|
|
|
2026-08-30 12:26:56 +02:00
|
|
|
|
// De badge staat BUITEN het hover-blok, en dat is geen slordigheid maar het
|
|
|
|
|
|
// verschil tussen een gegeven en een handeling. Hij stond er eerst in, en dan
|
|
|
|
|
|
// is niet te zien wélke van de twee soorten een regel is zolang je er niet
|
|
|
|
|
|
// overheen gaat: precies de informatie waarvoor de twee kaders zijn
|
|
|
|
|
|
// samengevoegd.
|
2026-09-10 09:54:22 +02:00
|
|
|
|
//
|
|
|
|
|
|
// Alleen "Blocked" nog, sinds 10-09-2026 op verzoek van de gebruiker. Een
|
|
|
|
|
|
// badge "Waiting" in een kader dat "Waiting" heet zegt niets extra; het
|
|
|
|
|
|
// onderscheid dat de badge moest maken blijft staan, want een geblokkeerde
|
|
|
|
|
|
// regel is nu precies de regel mét badge.
|
2026-08-30 12:26:56 +02:00
|
|
|
|
var tags = document.createElement('div');
|
|
|
|
|
|
tags.className = 'owner-tags';
|
|
|
|
|
|
if (kind === 'blocked') { tags.appendChild(badge('Blocked', 'badge-neg')); }
|
|
|
|
|
|
row.appendChild(tags);
|
2026-08-28 11:22:32 +02:00
|
|
|
|
|
2026-08-30 12:26:56 +02:00
|
|
|
|
var actions = document.createElement('div');
|
|
|
|
|
|
actions.className = 'owner-actions hover-only';
|
|
|
|
|
|
|
|
|
|
|
|
actions.appendChild(button(entry.label ? 'Rename' : 'Label', '', function () {
|
|
|
|
|
|
startEditing(row, who, entry);
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
if (kind === 'allowed') {
|
|
|
|
|
|
actions.appendChild(button('Block', 'btn-danger', function () {
|
|
|
|
|
|
send({ action: 'block', ownerId: entry.id });
|
|
|
|
|
|
}));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
actions.appendChild(button('Allow', 'btn-solid', function () {
|
|
|
|
|
|
send({ action: 'allow', ownerId: entry.id });
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
actions.appendChild(button('Forget', 'btn-danger', function () {
|
|
|
|
|
|
send({ action: 'forget', ownerId: entry.id });
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
row.appendChild(actions);
|
2026-08-28 11:22:32 +02:00
|
|
|
|
return row;
|
2026-08-30 12:26:56 +02:00
|
|
|
|
}
|
2026-08-28 11:22:32 +02:00
|
|
|
|
|
2026-08-30 12:26:56 +02:00
|
|
|
|
// Bij benadering en niet op de seconde. Hier stonden volledige tijdstempels, en
|
|
|
|
|
|
// twee daarvan naast elkaar liepen op een halve kaartbreedte over drie regels;
|
|
|
|
|
|
// dan is de onderregel drukker dan het id erboven. Wat je van een eigenaar wil
|
|
|
|
|
|
// weten is "is dit van vandaag of van vorige maand", en dat staat hier nu.
|
|
|
|
|
|
// Het exacte tijdstip blijft bereikbaar als tooltip; zie ownerRow.
|
|
|
|
|
|
function describe(entry, kind) {
|
|
|
|
|
|
var parts = [];
|
|
|
|
|
|
if (entry.firstSeen) { parts.push('first seen ' + formatAgo(entry.firstSeen)); }
|
|
|
|
|
|
if (entry.lastSeen) { parts.push('last seen ' + formatAgo(entry.lastSeen)); }
|
|
|
|
|
|
if (kind === 'rejected' && entry.attempts) {
|
|
|
|
|
|
parts.push(entry.attempts + (entry.attempts === 1 ? ' attempt' : ' attempts'));
|
|
|
|
|
|
}
|
|
|
|
|
|
return parts.join(' · ');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Het exacte tijdstip, voor de tooltip op de onderregel.
|
|
|
|
|
|
function describeExact(entry) {
|
|
|
|
|
|
var parts = [];
|
|
|
|
|
|
if (entry.firstSeen) { parts.push('First seen ' + formatTime(entry.firstSeen)); }
|
|
|
|
|
|
if (entry.lastSeen) { parts.push('Last seen ' + formatTime(entry.lastSeen)); }
|
|
|
|
|
|
return parts.join('\n');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function badge(text, kind) {
|
|
|
|
|
|
var node = document.createElement('span');
|
|
|
|
|
|
node.className = 'badge ' + kind;
|
|
|
|
|
|
node.textContent = text;
|
|
|
|
|
|
return node;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function button(text, extra, onClick) {
|
|
|
|
|
|
var node = document.createElement('button');
|
|
|
|
|
|
node.type = 'button';
|
|
|
|
|
|
node.className = 'btn' + (extra ? ' ' + extra : '');
|
|
|
|
|
|
node.textContent = text;
|
|
|
|
|
|
node.addEventListener('click', onClick);
|
|
|
|
|
|
return node;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Labelen ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
// Het veld komt in de plaats van de eerste regel van de rij. Enter bewaart,
|
|
|
|
|
|
// Escape breekt af, en de focus verliezen bewaart ook: dat laatste is wat je
|
|
|
|
|
|
// verwacht als je ergens anders klikt nadat je iets ingetypt hebt.
|
|
|
|
|
|
function startEditing(row, who, entry) {
|
|
|
|
|
|
if (editing !== null) { return; }
|
|
|
|
|
|
editing = entry.id;
|
|
|
|
|
|
|
|
|
|
|
|
var vorm = who.innerHTML;
|
|
|
|
|
|
who.textContent = '';
|
|
|
|
|
|
|
|
|
|
|
|
var input = document.createElement('input');
|
|
|
|
|
|
input.className = 'label-input';
|
|
|
|
|
|
input.type = 'text';
|
|
|
|
|
|
input.maxLength = 48;
|
|
|
|
|
|
input.value = entry.label || '';
|
|
|
|
|
|
input.placeholder = 'A name for this owner';
|
|
|
|
|
|
who.appendChild(input);
|
|
|
|
|
|
|
|
|
|
|
|
var afgerond = false;
|
|
|
|
|
|
|
|
|
|
|
|
function stop() {
|
|
|
|
|
|
if (afgerond) { return; }
|
|
|
|
|
|
afgerond = true;
|
|
|
|
|
|
editing = null;
|
|
|
|
|
|
who.innerHTML = vorm;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function bewaar() {
|
|
|
|
|
|
if (afgerond) { return; }
|
|
|
|
|
|
var waarde = input.value.trim();
|
|
|
|
|
|
var ongewijzigd = waarde === (entry.label || '');
|
|
|
|
|
|
stop();
|
|
|
|
|
|
if (!ongewijzigd) { sendLabel(entry.id, waarde); }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
input.addEventListener('keydown', function (e) {
|
|
|
|
|
|
if (e.key === 'Enter') { e.preventDefault(); bewaar(); }
|
|
|
|
|
|
// Escape zou anders ook de dialoog of het menu raken; hier hoort hij bij het
|
|
|
|
|
|
// veld en verder niets.
|
|
|
|
|
|
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); stop(); }
|
|
|
|
|
|
});
|
|
|
|
|
|
input.addEventListener('blur', bewaar);
|
|
|
|
|
|
|
|
|
|
|
|
input.focus();
|
|
|
|
|
|
input.select();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── De lijsten ────────────────────────────────────────────────────────────
|
2026-09-07 19:24:40 +02:00
|
|
|
|
// Meest recent gezien bovenaan, over de groepen heen: in het kader met
|
|
|
|
|
|
// geweigerden staat een blokkade van vanmorgen dus boven een afgewezen poging van
|
|
|
|
|
|
// vorige week, en niet andersom omdat "blocked" nu eenmaal eerst kwam. Tot 0.5.5
|
|
|
|
|
|
// stonden de rijen in de volgorde van het bestand, en dat is de volgorde waarin
|
|
|
|
|
|
// eigenaars ooit binnenkwamen; die zegt na een week niets meer. Op verzoek van
|
|
|
|
|
|
// de gebruiker, 07-09-2026.
|
|
|
|
|
|
//
|
|
|
|
|
|
// De volgorde verschuift vanzelf mee: de ronde van vijf seconden bouwt de lijst
|
|
|
|
|
|
// elke keer opnieuw op, dus hier hoeft niets voor herladen te worden. Wat
|
|
|
|
|
|
// "gezien" hier betekent staat bij het relay-proces: het tijdstip van de laatste
|
|
|
|
|
|
// verbinding, niet van de laatste sync erover.
|
|
|
|
|
|
function seenAt(entry) {
|
|
|
|
|
|
var when = new Date(entry.lastSeen || entry.firstSeen || 0).getTime();
|
|
|
|
|
|
return isNaN(when) ? 0 : when;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-30 12:26:56 +02:00
|
|
|
|
function fillList(id, groups, emptyText) {
|
|
|
|
|
|
var holder = el(id);
|
2026-09-07 19:24:40 +02:00
|
|
|
|
var rows = [];
|
|
|
|
|
|
groups.forEach(function (group) {
|
|
|
|
|
|
group.entries.forEach(function (entry) {
|
|
|
|
|
|
rows.push({ entry: entry, kind: group.kind });
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-08-30 12:26:56 +02:00
|
|
|
|
|
|
|
|
|
|
holder.textContent = '';
|
2026-09-07 19:24:40 +02:00
|
|
|
|
if (rows.length === 0) {
|
2026-08-28 11:22:32 +02:00
|
|
|
|
var empty = document.createElement('p');
|
2026-08-30 12:26:56 +02:00
|
|
|
|
empty.className = 'list-empty';
|
2026-08-28 11:22:32 +02:00
|
|
|
|
empty.textContent = emptyText;
|
2026-08-30 12:26:56 +02:00
|
|
|
|
holder.appendChild(empty);
|
2026-08-28 11:22:32 +02:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-07 19:24:40 +02:00
|
|
|
|
rows.sort(function (a, b) { return seenAt(b.entry) - seenAt(a.entry); });
|
|
|
|
|
|
rows.forEach(function (row) {
|
|
|
|
|
|
holder.appendChild(ownerRow(row.entry, row.kind));
|
2026-08-30 12:26:56 +02:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── De ronde ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
function refresh() {
|
2026-08-28 11:22:32 +02:00
|
|
|
|
fetch('api/status', { cache: 'no-store' }).then(function (response) {
|
|
|
|
|
|
return response.json();
|
|
|
|
|
|
}).then(function (status) {
|
2026-08-30 12:26:56 +02:00
|
|
|
|
hide('alert-agent');
|
2026-08-28 11:22:32 +02:00
|
|
|
|
|
2026-09-08 16:36:30 +02:00
|
|
|
|
// De versie in de kop. Geen put(): die zet "unknown" neer als het ontbreekt,
|
|
|
|
|
|
// en een kop met "vunknown" is erger dan een kop zonder versie.
|
|
|
|
|
|
if (status.version) { el('head-version').textContent = 'v' + status.version; }
|
|
|
|
|
|
|
2026-08-30 12:26:56 +02:00
|
|
|
|
// De relay.
|
|
|
|
|
|
var relay = el('relay-state');
|
|
|
|
|
|
relay.classList.remove('ok', 'bad');
|
|
|
|
|
|
if (status.relay.reachable === true) {
|
|
|
|
|
|
put('relay-state', 'Running');
|
|
|
|
|
|
relay.classList.add('ok');
|
|
|
|
|
|
el('relay-sub').textContent = 'accepting connections on port ' + status.relay.publicPort;
|
|
|
|
|
|
} else if (status.relay.reachable === false) {
|
|
|
|
|
|
put('relay-state', 'Not running');
|
|
|
|
|
|
relay.classList.add('bad');
|
|
|
|
|
|
el('relay-sub').textContent = 'it did not accept a connection from inside the app';
|
2026-08-28 11:22:32 +02:00
|
|
|
|
} else {
|
2026-08-30 12:26:56 +02:00
|
|
|
|
put('relay-state', null);
|
|
|
|
|
|
el('relay-sub').textContent = 'no reading yet';
|
2026-08-28 11:22:32 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-30 12:26:56 +02:00
|
|
|
|
// De database.
|
|
|
|
|
|
put('db-size', formatBytes(status.database.bytes));
|
|
|
|
|
|
el('db-sub').textContent = status.database.bytes === null
|
|
|
|
|
|
? 'the relay has not created its database yet'
|
|
|
|
|
|
: 'encrypted, and padded so the size says nothing';
|
2026-08-28 11:22:32 +02:00
|
|
|
|
|
2026-08-30 12:26:56 +02:00
|
|
|
|
put('db-modified', formatAgo(status.database.modified));
|
|
|
|
|
|
el('db-modified-sub').textContent = formatTime(status.database.modified) || 'never written to';
|
2026-08-28 11:22:32 +02:00
|
|
|
|
|
2026-09-09 15:45:55 +02:00
|
|
|
|
// ws:// en niet http://, en dat is geen schoonheidsfoutje: de relay is een
|
|
|
|
|
|
// WebSocket-server en spreekt nooit HTTP tegen een cliënt. Er stond http://
|
|
|
|
|
|
// tot 0.7.0, en dat kostte op 09-09-2026 een ronde zoeken bij het koppelen
|
|
|
|
|
|
// van de testcliënt.
|
2026-08-30 12:26:56 +02:00
|
|
|
|
el('relay-url').textContent =
|
2026-09-09 15:45:55 +02:00
|
|
|
|
'ws://' + window.location.hostname + ':' + status.relay.publicPort;
|
2026-08-30 12:26:56 +02:00
|
|
|
|
el('relay-url').classList.remove('unknown');
|
|
|
|
|
|
|
|
|
|
|
|
// De lijsten, tenzij er iemand aan een label bezig is.
|
|
|
|
|
|
if (editing === null) {
|
|
|
|
|
|
fillList('allowed', [
|
|
|
|
|
|
{ kind: 'allowed', entries: status.owners.allowed }
|
2026-09-09 15:45:55 +02:00
|
|
|
|
], 'None yet. Connect your app; it will show up in the waiting list.');
|
2026-08-30 12:26:56 +02:00
|
|
|
|
|
|
|
|
|
|
fillList('refused', [
|
|
|
|
|
|
{ kind: 'blocked', entries: status.owners.blocked },
|
|
|
|
|
|
{ kind: 'rejected', entries: status.owners.rejected }
|
|
|
|
|
|
], 'None.');
|
|
|
|
|
|
}
|
2026-08-28 11:22:32 +02:00
|
|
|
|
|
2026-09-09 15:45:55 +02:00
|
|
|
|
// De teller met wachtenden. Dit is de plek waar tot 0.6.0 het tijdvenster
|
|
|
|
|
|
// stond; nu telt hij wie er op een beslissing wacht, want dat is sinds
|
|
|
|
|
|
// 0.7.0 de enige weg naar binnen.
|
|
|
|
|
|
var wachtend = (status.owners.rejected || []).length;
|
|
|
|
|
|
put('waiting-state', String(wachtend));
|
|
|
|
|
|
el('waiting-state').classList.remove('unknown');
|
|
|
|
|
|
el('waiting-sub').textContent = wachtend === 0
|
|
|
|
|
|
? 'nobody is asking to get in'
|
|
|
|
|
|
: (wachtend === 1 ? 'one owner wants in; allow it below if it is yours'
|
|
|
|
|
|
: 'owners want in; allow the ones you recognise below');
|
|
|
|
|
|
|
2026-08-28 11:22:32 +02:00
|
|
|
|
if (status.owners.problem === 'onleesbaar') {
|
2026-09-09 15:45:55 +02:00
|
|
|
|
show('alert-owners', 'The list of owners could not be read and was moved aside. Everyone is '
|
|
|
|
|
|
+ 'on the waiting list again; allow the owners you recognise below.');
|
2026-08-30 12:26:56 +02:00
|
|
|
|
} else {
|
|
|
|
|
|
hide('alert-owners');
|
2026-08-28 11:22:32 +02:00
|
|
|
|
}
|
2026-08-30 12:26:56 +02:00
|
|
|
|
|
2026-08-30 13:17:26 +02:00
|
|
|
|
// De melding bovenaan is van deze ronde, niet van de klik die hem opriep.
|
|
|
|
|
|
// Hier stond alleen de tak die hem aanzet, en dus bleef "Sent." staan tot je
|
|
|
|
|
|
// de pagina herlaadde; gemeld door de gebruiker op 30-08-2026.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Een weigering is de uitzondering en wordt overgeslagen: die mag niet binnen
|
|
|
|
|
|
// vijf seconden verdwijnen, want dan is de enige uitleg van wat er misging weg
|
|
|
|
|
|
// voordat je hem gelezen hebt. Hij gaat weg bij de volgende opdracht.
|
|
|
|
|
|
if (!refusal) {
|
|
|
|
|
|
if (status.pendingCommand) {
|
|
|
|
|
|
// Ligt de opdracht er nog, dan heeft het relay-proces hem niet opgepakt.
|
|
|
|
|
|
// Dat is het geval waarin een melding écht iets toevoegt: normaal is dat
|
|
|
|
|
|
// binnen twee seconden gebeurd.
|
|
|
|
|
|
show('alert-notice', 'A command is waiting for the relay to pick it up.', 'alert-neutral');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
hide('alert-notice');
|
|
|
|
|
|
}
|
2026-08-30 12:26:56 +02:00
|
|
|
|
}
|
|
|
|
|
|
}, function () {
|
|
|
|
|
|
show('alert-agent');
|
|
|
|
|
|
put('relay-state', null);
|
|
|
|
|
|
el('relay-state').classList.remove('ok', 'bad');
|
2026-08-28 11:22:32 +02:00
|
|
|
|
});
|
2026-08-30 12:26:56 +02:00
|
|
|
|
}
|
2026-08-28 11:22:32 +02:00
|
|
|
|
|
|
|
|
|
|
refresh();
|
|
|
|
|
|
window.setInterval(refresh, 5000);
|
|
|
|
|
|
})();
|
|
|
|
|
|
</script>
|
|
|
|
|
|
</body>
|
|
|
|
|
|
</html>
|