Meest recent bovenaan, in beide kaders en over de groepen heen. De rijen stonden in de volgorde van het bestand, dus van binnenkomst. De volgorde schuift mee zonder herladen: de ronde van vijf seconden bouwt de lijsten toch al opnieuw op. Alleen de pagina, dus geen nieuwe image. Erbij gevonden en als open taak genoteerd: lastSeen wordt alleen bij de verbinding gezet, niet bij een sync erover. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1442 lines
66 KiB
Plaintext
1442 lines
66 KiB
Plaintext
<!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.
|
||
|
||
LET OP, en dit is de valstrik van dit bestand: umbreld haalt elke *.template
|
||
bij het starten door envsubst. Er mag dus GEEN dollarteken in staan behalve de
|
||
umbrel-variabelen die hier werkelijk ingevuld moeten worden. Dat sluit ook
|
||
JavaScript-template-literals uit, want die gebruiken accolades achter een
|
||
dollarteken en zouden stilzwijgend leeg worden. Alles hieronder plakt strings
|
||
dus met een plus aan elkaar. tests/test_appstore_vorm.py houdt dit dicht.
|
||
|
||
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.
|
||
|
||
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.
|
||
|
||
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.
|
||
═══════════════════════════════════════════════════════════════════════════════
|
||
-->
|
||
<!-- 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">
|
||
<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;
|
||
--radius-xl: 28px;
|
||
--radius-full: 999px;
|
||
--font-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
--font-mono: 'SF Mono', 'Fira Code', 'Cascadia Mono', Consolas, monospace;
|
||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||
}
|
||
|
||
[data-theme="dark"][data-accent="orange"] {
|
||
--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;
|
||
}
|
||
|
||
/* -- Diepte ------------------------------------------------------------------
|
||
Overgenomen uit HomeGit/Docs/website-design-system.html (31-08-2026). In
|
||
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.
|
||
|
||
**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. */
|
||
:root {
|
||
--surface: var(--bg-elevated);
|
||
--surface-inset: var(--bg-raised);
|
||
--surface-blur: 0px;
|
||
--surface-sat: 100%;
|
||
--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%);
|
||
}
|
||
/* 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%;
|
||
--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) {
|
||
/* 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"] {
|
||
--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; }
|
||
}
|
||
|
||
/* 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.
|
||
|
||
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. */
|
||
|
||
/* 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;
|
||
}
|
||
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
|
||
body {
|
||
font-family: var(--font-base);
|
||
background: var(--bg);
|
||
color: var(--text-primary);
|
||
-webkit-font-smoothing: antialiased;
|
||
padding: 2rem 1.75rem 3rem;
|
||
}
|
||
|
||
/* Dezelfde maat als Electrum Gate. Breed, maar niet oneindig: op een ultrawide
|
||
monitor zou een kader van 2500px regels opleveren die niemand leest. */
|
||
.wrap { position: relative; z-index: 1; max-width: 1760px; margin: 0 auto; }
|
||
|
||
/* ── 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;
|
||
}
|
||
|
||
/* ── Kaarten ────────────────────────────────────────────────────────────── */
|
||
.card {
|
||
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);
|
||
--padding: 1.8rem;
|
||
border-radius: var(--radius);
|
||
padding: var(--padding);
|
||
margin-bottom: 1rem;
|
||
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;
|
||
}
|
||
/* 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; }
|
||
[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;
|
||
}
|
||
|
||
/* ── 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.
|
||
|
||
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); }
|
||
/* 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); }
|
||
/* 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;
|
||
background: var(--bg-raised);
|
||
border: 1px solid var(--border);
|
||
border-radius: max(4px, calc(var(--radius-lg) - 1.8rem));
|
||
padding: 0.9rem 1.1rem;
|
||
overflow-wrap: anywhere;
|
||
color: var(--accent);
|
||
}
|
||
|
||
/* ── 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; }
|
||
.list-item-title { font-size: 0.88rem; font-weight: 500; overflow-wrap: anywhere; }
|
||
.list-item-title.mono { font-family: var(--font-mono); font-size: 0.8rem; font-weight: 400; }
|
||
.list-item-sub { font-size: 0.72rem; color: var(--text-sec); margin-top: 0.1rem; overflow-wrap: anywhere; }
|
||
.list-item-sub.mono { font-family: var(--font-mono); }
|
||
.list-empty { font-size: 0.78rem; color: var(--text-ter); padding: 0.7rem 0; }
|
||
.owner-who { min-width: 0; }
|
||
/* 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; }
|
||
.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); }
|
||
.badge-neutral { background: var(--neutral-bg); color: var(--text-sec); }
|
||
|
||
/* ── 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);
|
||
background: var(--bg-elevated);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-lg);
|
||
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; }
|
||
}
|
||
|
||
@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; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="wrap">
|
||
|
||
<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. -->
|
||
<p class="t-body-sm">Encrypted sync and backup for your local-first apps <span class="head-version">v${APP_VERSION}</span></p>
|
||
</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>
|
||
</header>
|
||
|
||
<!-- 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>
|
||
|
||
<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>
|
||
</div>
|
||
|
||
<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>
|
||
</div>
|
||
|
||
<!-- Het tijdvenster voor nieuwe eigenaars. De twee knoppen staan in de kop
|
||
en verschijnen bij hover over dit kader.
|
||
|
||
De teller loopt in het relay-proces en niet hier: een teller in de
|
||
browser verdwijnt als je het tabblad sluit, en dan blijft de deur open
|
||
staan zonder dat iemand dat ziet. Wat de pagina doet is de resterende
|
||
tijd van de agent aftellen tussen twee rondes. -->
|
||
<div class="card">
|
||
<div class="card-head">
|
||
<h2 class="t-h3">New owners</h2>
|
||
<!-- "Open" en niet "Open 2 min": de twee minuten staan op de onderregel,
|
||
waar er ruimte voor is. Met de lange tekst erin brak de kop van dit
|
||
kader over twee regels en stond de waarde lager dan in de drie
|
||
kaders ernaast. -->
|
||
<div class="hover-only">
|
||
<button class="btn btn-solid hidden" id="learn-open" type="button">Open</button>
|
||
<button class="btn btn-danger hidden" id="learn-stop" type="button">Close</button>
|
||
</div>
|
||
</div>
|
||
<div class="stat-body">
|
||
<div class="stat-val text unknown" id="learning-state">unknown</div>
|
||
<div class="stat-sub" id="learning-sub"></div>
|
||
</div>
|
||
</div>
|
||
<!-- 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>
|
||
</div>
|
||
|
||
<!-- ── De twee lijsten ───────────────────────────────────────────────── -->
|
||
<div class="card col-6">
|
||
<div class="card-head"><h2 class="t-h3">Accepted owners</h2></div>
|
||
<div id="allowed"></div>
|
||
</div>
|
||
|
||
<!-- Geweigerd en geblokkeerd in één kader, op verzoek van de gebruiker
|
||
(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
|
||
badge op de regel. -->
|
||
<div class="card col-6">
|
||
<div class="card-head"><h2 class="t-h3">Refused owners</h2></div>
|
||
<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>
|
||
<div class="url-box" id="relay-url">unknown</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 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>
|
||
|
||
<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
|
||
over who may use it. Opening the window for new owners accepts and remembers whoever connects
|
||
while it is open, so open it only while you pair a device of your own. One app can use more
|
||
than one owner id, so wait until nothing new appears before you close it again.
|
||
</p>
|
||
<p>
|
||
An owner that is refused 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.
|
||
</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>
|
||
</div>
|
||
</dialog>
|
||
</div>
|
||
|
||
<script>
|
||
(function () {
|
||
'use strict';
|
||
|
||
// Hoelang de deur open gaat als je op de knop drukt. Twee minuten, op verzoek
|
||
// van de gebruiker (30-08-2026). Deze waarde gaat mee in de opdracht; de agent
|
||
// en het relay-proces begrenzen hem allebei nog een keer, want dit is de kant
|
||
// die van buiten komt.
|
||
var LEARNING_SECONDS = 120;
|
||
|
||
var root = document.documentElement;
|
||
|
||
// 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.
|
||
var busy = false;
|
||
|
||
// 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;
|
||
|
||
// 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;
|
||
|
||
// De resterende seconden van het tijdvenster, zoals de agent ze doorgaf, en het
|
||
// moment waarop dat was. Daartussen telt de pagina zelf af, zodat de teller
|
||
// loopt in plaats van elke vijf seconden te verspringen.
|
||
var learningLeft = null;
|
||
var learningLeftAt = 0;
|
||
|
||
// ── 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');
|
||
}
|
||
}
|
||
|
||
function show(id, text, kind) {
|
||
var node = el(id);
|
||
var body = el(id + '-body');
|
||
if (body && text) { body.textContent = text; }
|
||
if (kind) {
|
||
node.classList.remove('alert-neutral', 'alert-warn', 'alert-error');
|
||
node.classList.add(kind);
|
||
}
|
||
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 formatClock(seconds) {
|
||
var whole = Math.max(0, Math.round(seconds));
|
||
var minutes = Math.floor(whole / 60);
|
||
var rest = whole % 60;
|
||
return minutes + ':' + (rest < 10 ? '0' : '') + rest;
|
||
}
|
||
|
||
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) {
|
||
root.setAttribute('data-theme', theme);
|
||
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 */ }
|
||
});
|
||
|
||
// ── 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');
|
||
|
||
function closeMenu() { menu.removeAttribute('open'); }
|
||
|
||
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(); }
|
||
});
|
||
|
||
// ── De dialoog ────────────────────────────────────────────────────────────
|
||
var sheet = el('about-dialog');
|
||
|
||
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', '');
|
||
}
|
||
});
|
||
|
||
// ── Opdrachten ────────────────────────────────────────────────────────────
|
||
function post(path, body, whenDone) {
|
||
return fetch(path, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body)
|
||
}).then(function (response) {
|
||
return response.json().then(function (parsed) {
|
||
return { ok: response.ok, body: parsed };
|
||
}, function () {
|
||
return { ok: response.ok, body: {} };
|
||
});
|
||
}).then(function (result) {
|
||
if (!result.ok) {
|
||
refusal = true;
|
||
show('alert-notice', 'The app refused that: ' + (result.body.error || 'no reason given'),
|
||
'alert-error');
|
||
} else if (whenDone) {
|
||
whenDone();
|
||
}
|
||
}, function () {
|
||
refusal = true;
|
||
show('alert-notice', 'Could not reach the app.', 'alert-error');
|
||
});
|
||
}
|
||
|
||
// 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');
|
||
}
|
||
|
||
function send(command) {
|
||
if (busy) { return; }
|
||
busy = true;
|
||
clearNotice();
|
||
// De melding is met opzet kort van leven: de ronde hieronder haalt hem weg
|
||
// zodra het relay-proces de opdracht heeft opgepakt, en dan is de nieuwe stand
|
||
// in de kaders zelf te zien. Blijft hij staan, dan heeft de relay hem nog
|
||
// niet gezien, en dán is er iets te melden.
|
||
post('api/command', command, function () {
|
||
show('alert-notice', 'Sent. The relay applies changes within a few seconds.', 'alert-neutral');
|
||
}).then(function () {
|
||
busy = false;
|
||
window.setTimeout(refresh, 2500);
|
||
});
|
||
}
|
||
|
||
function sendLabel(ownerId, label) {
|
||
clearNotice();
|
||
// 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);
|
||
}
|
||
|
||
el('learn-open').addEventListener('click', function () {
|
||
send({ action: 'set-learning', value: true, seconds: LEARNING_SECONDS });
|
||
});
|
||
el('learn-stop').addEventListener('click', function () {
|
||
send({ action: 'set-learning', value: false });
|
||
});
|
||
|
||
// ── 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) {
|
||
var row = document.createElement('div');
|
||
row.className = 'list-item';
|
||
|
||
var who = document.createElement('div');
|
||
who.className = 'owner-who';
|
||
|
||
var title = document.createElement('div');
|
||
var sub = document.createElement('div');
|
||
|
||
// 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.
|
||
if (entry.label) {
|
||
title.className = 'list-item-title';
|
||
title.textContent = entry.label;
|
||
sub.className = 'list-item-sub mono';
|
||
sub.textContent = shorten(entry.id);
|
||
sub.title = entry.id;
|
||
} 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);
|
||
|
||
// Met een label is er een derde regel nodig voor de tijden, want de tweede is
|
||
// dan het id.
|
||
if (entry.label) {
|
||
var extra = document.createElement('div');
|
||
extra.className = 'list-item-sub';
|
||
extra.textContent = describe(entry, kind);
|
||
extra.title = describeExact(entry);
|
||
who.appendChild(extra);
|
||
}
|
||
|
||
row.appendChild(who);
|
||
|
||
// 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.
|
||
var tags = document.createElement('div');
|
||
tags.className = 'owner-tags';
|
||
if (kind === 'blocked') { tags.appendChild(badge('Blocked', 'badge-neg')); }
|
||
if (kind === 'rejected') { tags.appendChild(badge('Turned away', 'badge-neutral')); }
|
||
row.appendChild(tags);
|
||
|
||
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);
|
||
return row;
|
||
}
|
||
|
||
// 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 ────────────────────────────────────────────────────────────
|
||
// 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;
|
||
}
|
||
|
||
function fillList(id, groups, emptyText) {
|
||
var holder = el(id);
|
||
var rows = [];
|
||
groups.forEach(function (group) {
|
||
group.entries.forEach(function (entry) {
|
||
rows.push({ entry: entry, kind: group.kind });
|
||
});
|
||
});
|
||
|
||
holder.textContent = '';
|
||
if (rows.length === 0) {
|
||
var empty = document.createElement('p');
|
||
empty.className = 'list-empty';
|
||
empty.textContent = emptyText;
|
||
holder.appendChild(empty);
|
||
return;
|
||
}
|
||
|
||
rows.sort(function (a, b) { return seenAt(b.entry) - seenAt(a.entry); });
|
||
rows.forEach(function (row) {
|
||
holder.appendChild(ownerRow(row.entry, row.kind));
|
||
});
|
||
}
|
||
|
||
// ── Het tijdvenster ───────────────────────────────────────────────────────
|
||
function drawLearning(owners) {
|
||
var open = el('learn-open');
|
||
var stop = el('learn-stop');
|
||
var node = el('learning-state');
|
||
|
||
if (owners.learning === true) {
|
||
// Met een tijdslot toont de widget de teller; zonder tijdslot alleen dat hij
|
||
// openstaat. Dat verschil is echt en hoort zichtbaar te zijn: "open" zonder
|
||
// teller sluit zichzelf niet.
|
||
if (typeof learningLeft === 'number') {
|
||
put('learning-state', formatClock(remainingSeconds()));
|
||
el('learning-sub').textContent = 'closes on its own';
|
||
} else {
|
||
put('learning-state', 'Open');
|
||
el('learning-sub').textContent = 'stays open until you close it';
|
||
}
|
||
node.classList.remove('bad');
|
||
node.classList.add('ok');
|
||
open.classList.remove('hidden');
|
||
open.textContent = 'Restart';
|
||
stop.classList.remove('hidden');
|
||
return;
|
||
}
|
||
|
||
node.classList.remove('ok', 'bad');
|
||
if (owners.learning === false) {
|
||
put('learning-state', 'Closed');
|
||
// Hier staat wat de knop niet zegt: hoe lang hij open gaat. Dat is de reden
|
||
// dat de knop "Open" mag heten en niet "Open 2 min" hoeft te heten.
|
||
el('learning-sub').textContent = 'opens for two minutes at a time';
|
||
open.classList.remove('hidden');
|
||
open.textContent = 'Open';
|
||
stop.classList.add('hidden');
|
||
return;
|
||
}
|
||
|
||
// Onbekend is geen synonym voor uit: uit is een keuze, onbekend is een reden
|
||
// om te kijken. Dat verschil hoort de pagina te tonen.
|
||
put('learning-state', null);
|
||
el('learning-sub').textContent = 'the relay has not reported this yet';
|
||
open.classList.add('hidden');
|
||
stop.classList.add('hidden');
|
||
}
|
||
|
||
function remainingSeconds() {
|
||
if (typeof learningLeft !== 'number') { return 0; }
|
||
return Math.max(0, learningLeft - (Date.now() - learningLeftAt) / 1000);
|
||
}
|
||
|
||
// Eén keer per seconde de teller bijwerken. Alleen de teller: de rest van de
|
||
// pagina komt uit de ronde hieronder, die elke vijf seconden loopt.
|
||
window.setInterval(function () {
|
||
if (typeof learningLeft !== 'number') { return; }
|
||
var node = el('learning-state');
|
||
if (node.classList.contains('ok')) {
|
||
node.textContent = formatClock(remainingSeconds());
|
||
}
|
||
}, 1000);
|
||
|
||
// ── De ronde ──────────────────────────────────────────────────────────────
|
||
function refresh() {
|
||
fetch('api/status', { cache: 'no-store' }).then(function (response) {
|
||
return response.json();
|
||
}).then(function (status) {
|
||
hide('alert-agent');
|
||
|
||
// 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';
|
||
} else {
|
||
put('relay-state', null);
|
||
el('relay-sub').textContent = 'no reading yet';
|
||
}
|
||
|
||
// 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';
|
||
|
||
put('db-modified', formatAgo(status.database.modified));
|
||
el('db-modified-sub').textContent = formatTime(status.database.modified) || 'never written to';
|
||
|
||
el('relay-url').textContent =
|
||
'http://' + window.location.hostname + ':' + status.relay.publicPort;
|
||
el('relay-url').classList.remove('unknown');
|
||
|
||
// Het tijdvenster. De resterende tijd komt van de agent, want die rekent met
|
||
// de klok van de Umbrel en niet met die van de bezoeker.
|
||
if (typeof status.owners.learningSecondsLeft === 'number'
|
||
&& status.owners.learningSecondsLeft > 0) {
|
||
learningLeft = status.owners.learningSecondsLeft;
|
||
learningLeftAt = Date.now();
|
||
} else {
|
||
learningLeft = null;
|
||
}
|
||
drawLearning(status.owners);
|
||
|
||
// De lijsten, tenzij er iemand aan een label bezig is.
|
||
if (editing === null) {
|
||
fillList('allowed', [
|
||
{ kind: 'allowed', entries: status.owners.allowed }
|
||
], 'None yet. Open the window above and connect your app.');
|
||
|
||
fillList('refused', [
|
||
{ kind: 'blocked', entries: status.owners.blocked },
|
||
{ kind: 'rejected', entries: status.owners.rejected }
|
||
], 'None.');
|
||
}
|
||
|
||
if (status.owners.problem === 'onleesbaar') {
|
||
show('alert-owners', 'The list of owners could not be read and was moved aside. New owners '
|
||
+ 'are refused until you open the window above again.');
|
||
} else {
|
||
hide('alert-owners');
|
||
}
|
||
|
||
// 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');
|
||
}
|
||
}
|
||
}, function () {
|
||
show('alert-agent');
|
||
put('relay-state', null);
|
||
el('relay-state').classList.remove('ok', 'bad');
|
||
});
|
||
}
|
||
|
||
refresh();
|
||
window.setInterval(refresh, 5000);
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|