diff --git a/CHANGELOG.md b/CHANGELOG.md index ed3554da..dca0c04b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [Unreleased] + +### Änderungen +- Portal: vollbreites Dashboard-Layout (Klima & SmartHome oben, Pi-hole mit Donut, Dienste als kompakte Startleiste) + +--- + ## [1.116.0] — 2026-07-03 ### Features diff --git a/public/css/portal.css b/public/css/portal.css index 8d83841f..8720bede 100644 --- a/public/css/portal.css +++ b/public/css/portal.css @@ -74,6 +74,7 @@ --surface:#111a24; --surface-2:#16212e; --surface-3:#1b2836; --chip:rgba(255,255,255,.03); --line:rgba(255,255,255,.07); --line-2:rgba(255,255,255,.12); + --track:rgba(255,255,255,.08); --text:#e9eff6; --muted:#90a1b3; --faint:#5f6f7e; --teal:#34dcc6; --teal-dim:#1f9c8e; --coral:#ff9d6c; --coral-dim:#cf6f45; --amber:#f5c451; --green:#4ade80; --green-bg:rgba(74,222,128,.10); --green-bd:rgba(74,222,128,.25); @@ -86,6 +87,7 @@ --surface:#fffdf8; --surface-2:#f8f3ea; --surface-3:#f0e9db; --chip:rgba(60,45,25,.04); --line:rgba(60,45,25,.10); --line-2:rgba(60,45,25,.17); + --track:rgba(60,45,25,.10); --text:#2c2720; --muted:#6e6456; --faint:#766b59; --teal:#0e9b8a; --teal-dim:#0b7d70; --coral:#d9602f; --coral-dim:#b14d24; --amber:#b5811f; --green:#15924f; --green-bg:rgba(21,146,79,.10); --green-bd:rgba(21,146,79,.28); @@ -113,7 +115,7 @@ body::before{ content:""; position:fixed; inset:0; pointer-events:none; z-index:0; opacity:var(--grain); background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); } -.wrap{position:relative; z-index:1; max-width:1120px; margin:0 auto; padding:22px 24px 56px} +.wrap{position:relative; z-index:1; max-width:1560px; margin:0 auto; padding:22px 24px 56px} /* ============================================================ TOP BAR @@ -164,10 +166,13 @@ body::before{ .card::after{content:"";position:absolute;inset:0 0 auto 0;height:1px;background:linear-gradient(90deg,transparent,var(--line-2),transparent)} @keyframes rise{to{opacity:1;transform:none}} -/* 3-widget layout (Pi-hole removed) */ +/* control-first order: Klima → SmartHome → Pi-hole → Traffic → Dienste */ .c-device{grid-column:span 4; animation-delay:.04s} -.c-traffic{grid-column:span 8; animation-delay:.12s} -.c-services{grid-column:span 12; animation-delay:.20s} +.c-midea{grid-column:span 7; animation-delay:.04s} +.c-smarthome{grid-column:span 5; animation-delay:.10s} +.c-pihole{grid-column:span 5; animation-delay:.16s} +.c-traffic{grid-column:span 7; animation-delay:.22s} +.c-services{grid-column:span 12; animation-delay:.28s} .card h2{margin:0; font-family:var(--font-display); font-weight:700; letter-spacing:-.01em; font-size:15px; display:flex; align-items:center; gap:9px; color:var(--text)} @@ -175,6 +180,19 @@ body::before{ .card h2 .ic svg{width:15px;height:15px} .card .sub{color:var(--faint); font-size:12px; margin-left:auto; font-weight:500} +/* ============================================================ + STATUS STRIP (.strip.c-device) + ============================================================ */ +.strip{display:flex;flex-wrap:wrap;align-items:center;gap:10px 22px;padding:13px 18px;margin:18px 0; + border:1px solid var(--line);border-radius:14px;background:linear-gradient(180deg,var(--surface),var(--surface-2)); + box-shadow:var(--shadow)} +.strip .s{display:flex;align-items:center;gap:9px;font-size:13px} +.strip .s .lbl{color:var(--faint);font-size:11px;text-transform:uppercase;letter-spacing:.06em} +.strip .s .ip{font-family:var(--font-mono);color:var(--muted);font-size:12.5px} + +/* global focus-visible ring, ported from mockup */ +:where(button,a,[role="switch"],.seg button):focus-visible{outline:2px solid var(--teal);outline-offset:2px} + /* ============================================================ DEVICE STATUS CARD (.c-device) ============================================================ */ @@ -209,19 +227,19 @@ body::before{ .legend i{width:9px;height:9px;border-radius:3px;display:inline-block} /* ============================================================ - SERVICES CARD (.c-services — now full-width span 12) - Tiles use auto-fill / minmax(200px) so they don't render as 2 giant tiles - at full 12-column width. Still collapses to 2-up on narrow screens. + SERVICES CARD (.c-services — full-width span 12, compact launcher strip) ============================================================ */ -.tiles{display:grid; grid-template-columns:repeat(auto-fill, minmax(200px, 1fr)); gap:11px; margin-top:18px} -.tile{display:flex; flex-direction:column; gap:10px; padding:14px; border-radius:var(--r-sm); cursor:pointer; - background:var(--surface-3); border:1px solid var(--line); text-decoration:none; color:var(--text); transition:.16s} -.tile:hover{transform:translateY(-2px); border-color:var(--line-2); background:var(--surface-2)} -.tile .ti{width:34px;height:34px;border-radius:10px;display:grid;place-items:center; color:#fff} -.tile .ti svg{width:18px;height:18px} -.tile .tn{font-weight:600; font-size:13.5px} -.tile .td{font-size:11.5px; color:var(--faint); font-family:var(--font-mono)} -.tile.add{align-items:center; justify-content:center; border-style:dashed; color:var(--faint); background:transparent} +.svc-strip{display:grid; grid-template-columns:repeat(auto-fill, minmax(150px, 1fr)); gap:11px; margin-top:18px} +.svc{display:flex; align-items:center; gap:11px; padding:11px 13px; border:1px solid var(--line); border-radius:12px; + background:var(--chip); text-decoration:none; color:inherit; transition:.15s; cursor:pointer} +.svc:hover{border-color:var(--teal); transform:translateY(-2px)} +.svc .si{width:32px;height:32px;border-radius:9px;display:grid;place-items:center;flex:0 0 auto; + background:var(--surface-3); color:var(--teal); font-family:var(--font-mono); font-weight:700; font-size:13px} +.svc .st{min-width:0} +.svc .st b{display:block; font-size:13px; font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis} +.svc .st span{color:var(--faint); font-size:11px} +.svc .live{width:7px;height:7px;border-radius:50%;background:var(--green);margin-left:auto;flex:0 0 auto} +.svc:focus-visible{outline:2px solid var(--teal); outline-offset:2px} /* ============================================================ FOOTER @@ -232,11 +250,19 @@ body::before{ /* ============================================================ RESPONSIVE ============================================================ */ -@media (max-width:880px){ +@media (max-width:1040px){ .grid > .card{grid-column:span 12 !important} .devpill .ip{display:none} /* Keep service tiles 2-up on narrow screens (thumb-friendly) */ - .tiles{grid-template-columns:1fr 1fr} + .svc-strip{grid-template-columns:1fr 1fr} + .sh-sensors{grid-template-columns:repeat(4,1fr)} +} + +@media (max-width:620px){ + .wrap{padding:16px 16px 46px} + .c-midea .midea-list{grid-template-columns:1fr} + .sh-tiles{grid-template-columns:1fr} + .sh-sensors{grid-template-columns:1fr 1fr} } /* ============================================================ @@ -269,19 +295,18 @@ body::before{ .portal-empty{padding:24px 0;color:var(--faint);font-size:13px;text-align:center;grid-column:1/-1} /* Reserve height for services card while tiles load */ -.c-services.loading{min-height:200px} +.c-services.loading{min-height:110px} /* ============================================================ PI-HOLE WIDGET ============================================================ */ -.c-pihole .pihole-body{display:flex;flex-direction:column;gap:10px} -.pihole-rate{display:flex;align-items:baseline;gap:3px} -.pihole-rate b{font-size:2.2rem;font-weight:700;color:var(--teal, #3ec8b0);line-height:1} -.pihole-rate span{font-size:1.1rem;color:var(--muted)} -.pihole-bar{height:6px;border-radius:3px;background:var(--border);overflow:hidden} -.pihole-bar i{display:block;height:100%;border-radius:3px;background:var(--teal, #3ec8b0); - transition:width .4s ease;width:0} -.pihole-nums{display:flex;gap:12px;flex-wrap:wrap} +.c-pihole .pihole-body{display:flex;align-items:center;gap:20px} +.donut{position:relative;width:132px;height:132px;flex:0 0 auto} +.donut svg{transform:rotate(-90deg)} +.donut .dc{position:absolute;inset:0;display:grid;place-items:center;text-align:center} +.donut .dc b{font-size:1.9rem;font-weight:700;color:var(--coral);line-height:1;font-family:var(--font-mono, inherit)} +.donut .dc span{font-size:1rem;color:var(--muted)} +.pihole-nums{display:flex;flex-direction:column;gap:12px;flex:1} .pihole-nums>div{display:flex;flex-direction:column;gap:2px;min-width:60px} .pihole-nums b{font-size:1.1rem;font-weight:600;color:var(--text)} .pihole-nums span{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em} @@ -321,16 +346,21 @@ body::before{ .midea-card .ac-status.off{color:var(--muted);background:var(--chip);border:1px solid var(--line)} .midea-card .ac-status.off-line{color:var(--coral);background:var(--chip);border:1px solid var(--line-2)} -/* Klima-Block: Ring-Gauge + Stell-Spalte */ -.midea-card .ac-climate{display:flex;align-items:center;gap:16px;margin:16px 0;padding:16px 0; +/* Klima-Block: Temperatur-Ring (SVG, Soll-basiert) + Stell-Spalte */ +.midea-card .ac-climate.ac-ring{display:flex;align-items:center;gap:16px;margin:16px 0;padding:16px 0; border-top:1px solid var(--line);border-bottom:1px solid var(--line)} -.midea-card .ring{width:92px;height:92px;border-radius:50%;flex:0 0 auto;display:grid;place-items:center; - background:conic-gradient(var(--ring-c,var(--teal)) var(--ring-val,0%), var(--line-2) 0)} /* full-circle gauge; fill color per mode (heat→coral) */ -.midea-card .ring-in{width:72px;height:72px;border-radius:50%;background:var(--surface);display:flex; - flex-direction:column;align-items:center;justify-content:center;gap:1px} -.midea-card .ring-v{font-family:var(--font-display);font-weight:800;font-size:25px;line-height:1;color:var(--text)} -.midea-card .ring-l{font-size:9px;text-transform:uppercase;letter-spacing:.07em;color:var(--faint);font-weight:700} -.midea-card .ac-set{flex:1;min-width:0;display:flex;flex-direction:column;gap:13px} +.midea-card .ring{position:relative;width:106px;height:106px;flex:0 0 auto} +.midea-card .ring svg{transform:rotate(-90deg)} /* start the arc at 12 o'clock */ +.midea-card .ring svg circle{transition:stroke-dashoffset .3s, stroke .15s} /* fill color per mode (heat→coral) */ +.midea-card .ring .rc{position:absolute;inset:0;display:grid;place-items:center;text-align:center} +.midea-card .ring .rc .t{font-family:var(--font-display);font-weight:800;font-size:25px;line-height:1;color:var(--text)} +.midea-card .ring .rc .t sup{font-size:12px;position:relative;top:-.6em} +.midea-card .ring .rc .c{font-size:9px;text-transform:uppercase;letter-spacing:.07em;color:var(--faint); + font-weight:700;margin-top:3px} +.midea-card .ac-side{flex:1;min-width:0;display:flex;flex-direction:column;gap:8px} +.midea-card .ac-row{display:flex;align-items:center;justify-content:space-between;font-size:12.5px;color:var(--muted)} +.midea-card .ac-row b{color:var(--text);font-weight:600;font-family:var(--font-mono)} +.midea-card .ac-set{flex:1;min-width:0;display:flex;flex-direction:column;gap:13px;margin-top:13px} .midea-card .set-lbl{font-size:10.5px;text-transform:uppercase;letter-spacing:.07em;color:var(--faint); font-weight:700;margin-bottom:7px} @@ -345,14 +375,13 @@ body::before{ .midea-card .stepper.pending .v{color:var(--amber)} /* command in flight (optimistic) */ .midea-card .stepper.confirmed .v{color:var(--green)} /* device confirmed the setpoint */ -/* Modus-Segment: an die Admin-/midea-Karte angeglichen (.toggle-group/.mode-group): - gepolsterter Inset-Track mit Lücken statt Trennlinien, abgesetzte aktive Pille. */ -.midea-card .modes{display:flex;width:100%;gap:2px;padding:3px;border:1px solid var(--line);border-radius:10px;background:var(--bg-2)} -.midea-card .modes .m{flex:1 1 0;min-width:0;padding:7px 0;display:flex;align-items:center;justify-content:center; - border:0;border-radius:7px;background:transparent;color:var(--muted);cursor:pointer;transition:.12s} -.midea-card .modes .m svg{width:16px;height:16px} -.midea-card .modes .m:hover:not([disabled]):not(.active){color:var(--text)} -.midea-card .modes .m.active{background:var(--surface-3);color:var(--text);box-shadow:0 2px 8px -4px rgba(0,0,0,.35)} +/* Modus-Segment: Text-Chips wie im Redesign-Mockup (.modeseg), aktiv = Teal-Tönung, Heizen = Coral. */ +.midea-card .modeseg{display:flex;gap:5px;flex-wrap:wrap} +.midea-card .modeseg-opt{font-size:11px;padding:6px 10px;border-radius:8px;border:1px solid var(--line); + color:var(--muted);background:var(--chip);font-family:var(--font-body);font-weight:600;cursor:pointer;transition:.12s} +.midea-card .modeseg-opt:hover:not([disabled]):not(.on){color:var(--text)} +.midea-card .modeseg-opt.on{background:rgba(52,220,198,.14);color:var(--teal);border-color:transparent} +.midea-card .modeseg-opt.on.heat{background:rgba(255,157,108,.16);color:var(--coral)} /* Power-Aktion */ .midea-card .ac-foot{display:flex;gap:9px;align-items:center} @@ -366,8 +395,6 @@ body::before{ /* zweistufig: ungeloggt/offline → Steuerelemente gesperrt */ .midea-card [disabled]{opacity:.4;cursor:not-allowed;pointer-events:none} -/* Ring + Außentemp gestapelt */ -.midea-card .ring-wrap{display:flex;flex-direction:column;align-items:center;gap:7px;flex:0 0 auto} .midea-card .ac-outdoor{font-size:11px;color:var(--muted);font-weight:600;font-family:var(--font-mono)} /* Toggle-Chips (Turbo/Eco/Auto) */ @@ -398,18 +425,26 @@ body::before{ /* ============================================================ SMART HOME WIDGET ============================================================ */ -.c-sh-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:10px} -.c-sh-card{background:var(--surface-2,#16212e);border:1px solid var(--line,rgba(255,255,255,.08));border-radius:12px;padding:12px} -.c-sh-name{font-weight:600;font-size:14px;margin-bottom:10px} -.c-sh-sw{border:1px solid var(--line-2,rgba(255,255,255,.14));border-radius:8px;padding:6px 12px;cursor:pointer;background:var(--surface-3,#1b2836);color:inherit} -.c-sh-sw.on{background:linear-gradient(145deg,var(--green,#4ade80),#15924f);border-color:transparent;color:#fff} -.c-sh-bri{width:100%;margin-top:10px} +.sh-tiles{display:grid;grid-template-columns:1fr 1fr;gap:11px} +.sh-tile{display:flex;flex-wrap:wrap;align-items:center;gap:12px;padding:13px;border:1px solid var(--line);border-radius:13px;background:var(--chip)} +.sh-ic{width:38px;height:38px;border-radius:10px;display:grid;place-items:center;flex:0 0 auto; + background:var(--surface-3,rgba(255,255,255,.06));color:var(--muted)} +.sh-ic svg{width:19px;height:19px} +.sh-tile.on .sh-ic{background:rgba(245,196,81,.16);color:var(--amber)} +.sh-tile .nm{flex:1;min-width:0} +.sh-tile .nm b{display:block;font-size:13px;font-weight:600} +.sh-tile .nm span{color:var(--faint);font-size:11.5px} +.toggle{width:42px;height:24px;border-radius:999px;background:var(--track);position:relative;flex:0 0 auto;cursor:pointer;transition:.18s;border:0;padding:0} +.toggle::after{content:"";position:absolute;top:3px;left:3px;width:18px;height:18px;border-radius:50%;background:#fff;transition:.18s} +.sh-tile.on .toggle{background:var(--teal)} +.sh-tile.on .toggle::after{left:21px} +.sh-tile .c-sh-bri{flex:0 0 100%;margin:2px 0 0} +.sh-tile .btn-sm{flex:0 0 auto} +.sh-sensors{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-top:12px} +.sensor{padding:11px;border:1px solid var(--line);border-radius:12px;background:var(--chip);text-align:center} +.sensor .v{font-family:var(--font-mono);font-size:19px;font-weight:700} +.sensor .l{color:var(--faint);font-size:11px;margin-top:3px} .c-sh-msg{margin-top:10px;font-size:13px;color:var(--muted,#90a1b3)} -.c-sh-sensors{margin-top:12px;display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:8px} -.c-sh-sensor-head{grid-column:1/-1;font-size:12px;font-weight:600;color:var(--muted,#90a1b3);margin-top:6px} -.c-sh-sensor-card{background:var(--surface-2,#16212e);border:1px solid var(--line,rgba(255,255,255,.08));border-radius:10px;padding:10px} -.c-sh-sensor-name{font-size:13px;margin-bottom:4px} -.c-sh-sensor-val{font-size:16px;font-weight:600} /* ============================================================ REDUCED MOTION diff --git a/public/js/portal.js b/public/js/portal.js index c61a33b9..9179d16d 100644 --- a/public/js/portal.js +++ b/public/js/portal.js @@ -292,18 +292,11 @@ } // ─── Services widget ──────────────────────────────────────────────────────── - const TILE_COLORS = [ - 'linear-gradient(145deg,#ffb27a,#e8763f)', - 'linear-gradient(145deg,#7ae0d2,#28b3a2)', - 'linear-gradient(145deg,#9db8ff,#5b76d6)', - 'linear-gradient(145deg,var(--teal),var(--teal-dim))', - ]; - const TILE_ICON = - '' + - '' + - '' + - '' + - ''; + // Two-letter monogram derived from the service name for the compact launcher tile. + function serviceMonogram(name) { + const trimmed = String(name || '').trim(); + return (trimmed.slice(0, 1).toUpperCase() + trimmed.slice(1, 2).toLowerCase()) || '?'; + } function hydrateServices() { const card = document.querySelector('.c-services'); @@ -330,13 +323,12 @@ tilesEl.replaceChildren(emptyDiv); return; } - tilesEl.innerHTML = services.map(function (s, i) { - const color = TILE_COLORS[i % TILE_COLORS.length]; - return '' + - '' + TILE_ICON + '' + - '' + escHtml(s.name) + '' + - '' + escHtml(s.domain) + '' + + '' + escHtml(serviceMonogram(s.name)) + '' + + '' + escHtml(s.name) + '' + escHtml(s.domain) + '' + + '' + ''; }).join(''); }) @@ -400,8 +392,13 @@ // ── Stats common to all scopes ────────────────────────────────────── var pctEl = document.getElementById('piPct'); if (pctEl) pctEl.textContent = String(d.blockedPct); - var bar = document.getElementById('piBar'); - if (bar) bar.style.width = d.blockedPct + '%'; + var arc = document.getElementById('piDonut'); + if (arc) { + var r = 50, C = 2 * Math.PI * r; // r MUSS zum im Donut-Markup passen + var frac = Math.max(0, Math.min(1, (Number(d.blockedPct) || 0) / 100)); // d.blockedPct = dieselbe Block-Rate, die auch #piPct setzt + arc.setAttribute('stroke-dasharray', C.toFixed(1)); + arc.setAttribute('stroke-dashoffset', (C - frac * C).toFixed(1)); + } var totalEl = document.getElementById('piTotal'); if (totalEl) totalEl.textContent = String(d.total); var blockedEl = document.getElementById('piBlocked'); @@ -495,19 +492,12 @@ var FAN_STEPS = [1, 20, 40, 60, 80, 100]; // Prozent-Stufen wie Midea-App (1–100, 100=Max); Auto=102 außerhalb der Skala function fanIndex(v) { return FAN_STEPS.reduce(function (b, val, i, a) { return Math.abs(val - v) < Math.abs(a[b] - v) ? i : b; }, 0); } // Static icon strings (no user data → safe to inline). Mirrors the admin /midea card. - var MIDEA_MODE_ICONS = { // same icon set as the admin /midea card - auto: '', - cool: '', - heat: '', - dry: '', - fan: '', - }; var MIDEA_AC_ICON = ''; var MIDEA_POWER_ICON = ''; function mideaModeLabel(m) { return PT['mideaMode' + m.charAt(0).toUpperCase() + m.slice(1)] || m; } - // Redesign: card grid with conic-gradient temp-ring + stepper + icon mode-segment + status pill - // (modeled on the admin /midea card, in the portal design system). Data contract unchanged: + // Redesign: card grid with SVG target-temp ring (r=52, 16–30 °C arc) + stepper + text mode-segment + status pill + // (mirrors the portal-redesign mockup's .ac/.ac-ring/.stepper/.modeseg). Data contract unchanged: // .midea-card[data-id], .midea-status[data-power], button[data-act=power]/[data-step]/[data-mode], .midea-target[data-val]. function renderMideaCard(d) { var st = d.state || {}; @@ -515,8 +505,6 @@ var powered = !offline && !!st.power; var indoor = Number(st.indoorTemp); var hasIndoor = !offline && !isNaN(indoor); - var pct = hasIndoor ? Math.max(0, Math.min(100, ((indoor - 16) / 14) * 100)) : 0; // ring fill, 16–30 °C span - var ringC = (!offline && st.mode === 'heat') ? 'var(--coral)' : 'var(--teal)'; // heat → coral, else teal var temp = hasIndoor ? Math.round(indoor) + '°' : '—'; var tgt = Number(st.targetTemp); var hasTgt = !offline && !isNaN(tgt); @@ -543,10 +531,19 @@ '' + '' + ''; + // Temperature ring (target-based, NOT a 0–100% gauge): r=52 circle, 16–30 °C mapped to the arc. + var r = 52, C = 2 * Math.PI * r; + var MIN = 16, MAX = 30; + var frac = hasTgt ? Math.max(0, Math.min(1, (tgt - MIN) / (MAX - MIN))) : 0; + var dash = C.toFixed(1); + var dashOff = (C - frac * C).toFixed(1); + var ringC = offline ? 'var(--faint)' : ((st.mode === 'heat') ? 'var(--coral)' : 'var(--teal)'); // heat → coral, else teal + var ringBig = hasTgt ? String(Math.round(tgt)) : '—'; var modeBtns = MIDEA_MODES.map(function (m) { - var active = (!offline && st.mode === m) ? ' active' : ''; + var active = !offline && st.mode === m; + var cls = 'modeseg-opt' + (active ? ' on' : '') + (active && m === 'heat' ? ' heat' : ''); var lbl = escHtml(mideaModeLabel(m)); - return ''; + return ''; }).join(''); var meta = d.transport ? '
' + escHtml(d.transport === 'cloud' ? 'Cloud' : 'LAN') + '
' : ''; var powerBtn = offline @@ -560,14 +557,22 @@ '
' + escHtml(d.name) + '
' + meta + '
' + '' + escHtml(statusTxt) + '' + '' + - '
' + - '
' + escHtml(temp) + '' + escHtml(PT.mideaCurrent || '') + '
' + outdoor + '
' + - '
' + - '
' + escHtml(PT.mideaTarget || '') + '
' + - '
' + escHtml(tgtDisp) + '
' + - '
' + escHtml(PT.mideaMode || '') + '
' + modeBtns + '
' + fan + extras + + '
' + + '
' + + '' + + '' + + '
' + escHtml(ringBig) + '°
' + escHtml(PT.mideaTarget || '') + '
' + + '
' + + '
' + escHtml(PT.mideaCurrent || '') + '' + escHtml(temp) + '
' + + outdoor + + '
' + escHtml(PT.mideaTarget || '') + '' + + '
' + + '' + escHtml(tgtDisp) + '' + + '
' + + '
' + modeBtns + '
' + '
' + '
' + + '
' + fan + extras + '
' + '
' + powerBtn + '
' + login + '
'; } @@ -692,22 +697,55 @@ var msg = document.getElementById('smarthomeMsg'); if (msg) { msg.style.display = 'block'; msg.textContent = PT.smarthomeLoginToControl || 'Login required'; } } + // Static, hard-coded SVG per device kind — no user data interpolated, safe as innerHTML. + var SH_ICONS = { + light: '', + group: '', + plug: '', + switch: '', + scene: '' + }; + function shStatusText(st, caps) { + var onLabel = st.on ? (PT.smarthomeOn || 'On') : (PT.smarthomeOff || 'Off'); + if (st.on && caps.bri && st.bri != null) return onLabel + ' · ' + Math.round(Number(st.bri)) + ' %'; + return onLabel; + } function renderSmarthomeCard(d) { - var el = document.createElement('div'); el.className = 'c-sh-card'; + var el = document.createElement('div'); el.className = 'sh-tile'; var st = d.state || {}, caps = d.capabilities || {}; - var name = document.createElement('div'); name.className = 'c-sh-name'; name.textContent = d.name || ''; el.appendChild(name); + var ic = document.createElement('span'); ic.className = 'sh-ic'; ic.innerHTML = SH_ICONS[d.kind] || SH_ICONS.light; el.appendChild(ic); + var nm = document.createElement('div'); nm.className = 'nm'; + var b = document.createElement('b'); b.textContent = d.name || ''; nm.appendChild(b); + var sub = document.createElement('span'); nm.appendChild(sub); + el.appendChild(nm); if (d.kind === 'scene') { - var b = document.createElement('button'); b.className = 'btn btn-sm'; b.textContent = PT.smarthomeActivate || 'Activate'; - b.addEventListener('click', function () { shControl(d.id, {}, el); }); - el.appendChild(b); return el; + var btn = document.createElement('button'); btn.className = 'btn btn-sm'; btn.textContent = PT.smarthomeActivate || 'Activate'; + btn.addEventListener('click', function () { shControl(d.id, {}, el); }); + el.appendChild(btn); + return el; } - var sw = document.createElement('button'); sw.className = 'c-sh-sw' + (st.on ? ' on' : ''); - sw.textContent = PT.smarthomePower || 'Power'; - sw.addEventListener('click', function () { var on = !sw.classList.contains('on'); sw.classList.toggle('on', on); shControl(d.id, { on: on }, el); }); + sub.textContent = shStatusText(st, caps); + el.classList.toggle('on', !!st.on); + // a11y: toggle pill exposes role="switch" + aria-checked reflecting on/off state (WAI-ARIA switch pattern) + var sw = document.createElement('span'); + sw.className = 'toggle'; + sw.setAttribute('role', 'switch'); + sw.setAttribute('tabindex', '0'); + sw.setAttribute('aria-checked', st.on ? 'true' : 'false'); + sw.setAttribute('aria-label', d.name || ''); el.appendChild(sw); + var flip = function () { + var on = !el.classList.contains('on'); + el.classList.toggle('on', on); + sw.setAttribute('aria-checked', on ? 'true' : 'false'); + sub.textContent = shStatusText({ on: on, bri: st.bri }, caps); + shControl(d.id, { on: on }, el); + }; + sw.addEventListener('click', flip); + sw.addEventListener('keydown', function (ev) { if (ev.key === ' ' || ev.key === 'Enter') { ev.preventDefault(); flip(); } }); if (caps.bri) { var range = document.createElement('input'); range.type = 'range'; range.min = 0; range.max = 100; range.value = (st.bri != null ? st.bri : 0); range.className = 'c-sh-bri'; - range.addEventListener('change', function () { shControl(d.id, { bri: Number(range.value) }, el); }); + range.addEventListener('change', function () { st.bri = Number(range.value); sub.textContent = shStatusText({ on: el.classList.contains('on'), bri: st.bri }, caps); shControl(d.id, { bri: Number(range.value) }, el); }); el.appendChild(range); } return el; @@ -727,10 +765,10 @@ } } function renderSensorCard(s) { - var el = document.createElement('div'); el.className = 'c-sh-sensor-card'; + var el = document.createElement('div'); el.className = 'sensor'; var st = s.state || {}; - var name = document.createElement('div'); name.className = 'c-sh-sensor-name'; name.textContent = s.name || ''; el.appendChild(name); - var val = document.createElement('div'); val.className = 'c-sh-sensor-val'; val.textContent = formatSensor(st.type, st.value); el.appendChild(val); + var val = document.createElement('div'); val.className = 'v'; val.textContent = formatSensor(st.type, st.value); el.appendChild(val); + var lbl = document.createElement('div'); lbl.className = 'l'; lbl.textContent = s.name || ''; el.appendChild(lbl); return el; } function hydrateSmarthome() { @@ -746,9 +784,8 @@ if (sensorBox) { sensorBox.innerHTML = ''; var sensors = (j && j.data && j.data.sensors) || []; + sensorBox.setAttribute('aria-label', PT.smarthomeSensors || 'Sensors'); if (sensors.length) { - var head = document.createElement('div'); head.className = 'c-sh-sensor-head'; head.textContent = PT.smarthomeSensors || 'Sensors'; - sensorBox.appendChild(head); sensors.forEach(function (s) { sensorBox.appendChild(renderSensorCard(s)); }); sensorBox.style.display = ''; } else { diff --git a/src/i18n/de.json b/src/i18n/de.json index 071c6928..b575d068 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -2195,6 +2195,8 @@ "portal.midea.turbo": "Turbo", "portal.midea.eco": "Eco", "portal.midea.outdoor": "Außen", + "portal.midea.warmer": "wärmer", + "portal.midea.cooler": "kälter", "portal.smarthome.title": "Smart Home", "portal.smarthome.power": "Ein/Aus", "portal.smarthome.brightness": "Helligkeit", @@ -2206,5 +2208,7 @@ "portal.smarthome.motion": "Bewegung", "portal.smarthome.no_motion": "keine Bewegung", "portal.smarthome.wet": "nass", - "portal.smarthome.dry": "trocken" + "portal.smarthome.dry": "trocken", + "portal.smarthome.on": "An", + "portal.smarthome.off": "Aus" } diff --git a/src/i18n/en.json b/src/i18n/en.json index 2eb4ab12..f0f9b99a 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -2251,6 +2251,8 @@ "portal.midea.turbo": "Turbo", "portal.midea.eco": "Eco", "portal.midea.outdoor": "Outdoor", + "portal.midea.warmer": "warmer", + "portal.midea.cooler": "cooler", "portal.smarthome.title": "Smart Home", "portal.smarthome.power": "Power", "portal.smarthome.brightness": "Brightness", @@ -2262,5 +2264,7 @@ "portal.smarthome.motion": "Motion", "portal.smarthome.no_motion": "No motion", "portal.smarthome.wet": "Wet", - "portal.smarthome.dry": "Dry" + "portal.smarthome.dry": "Dry", + "portal.smarthome.on": "On", + "portal.smarthome.off": "Off" } diff --git a/templates/portal/portal.njk b/templates/portal/portal.njk index 8b554191..759c56fb 100644 --- a/templates/portal/portal.njk +++ b/templates/portal/portal.njk @@ -58,6 +58,8 @@ mideaTurbo: t('portal.midea.turbo'), mideaEco: t('portal.midea.eco'), mideaOutdoor: t('portal.midea.outdoor'), + mideaWarmer: t('portal.midea.warmer'), + mideaCooler: t('portal.midea.cooler'), smarthomePower: t('portal.smarthome.power'), smarthomeBrightness: t('portal.smarthome.brightness'), smarthomeActivate: t('portal.smarthome.activate'), @@ -68,7 +70,9 @@ smarthomeMotion: t('portal.smarthome.motion'), smarthomeNoMotion: t('portal.smarthome.no_motion'), smarthomeWet: t('portal.smarthome.wet'), - smarthomeDry: t('portal.smarthome.dry') + smarthomeDry: t('portal.smarthome.dry'), + smarthomeOn: t('portal.smarthome.on'), + smarthomeOff: t('portal.smarthome.off') } | dump | safe }} @@ -113,51 +117,36 @@

{{ t('portal.greeting_sub') }}

+ {% if widgets.device %} + +
+ + {{ t('portal.device.vpn_address') }} + {{ t('portal.device.dns') }} + {{ t('portal.device.last_handshake') }} + +
+ {% endif %} +
- {% if widgets.device %} - -
-

{{ t('portal.device.title') }}

-
-
{{ t('portal.device.status') }}
-
{{ t('portal.device.last_handshake') }}
-
{{ t('portal.device.vpn_address') }}
-
{{ t('portal.device.dns') }}
-
-
-
{{ t('portal.device.received') }}
-
{{ t('portal.device.sent') }}
-
-
- {% endif %} - - {% if widgets.traffic %} - -
-

{{ t('portal.traffic.title') }} - {{ t('portal.traffic.legend_rx') }}{{ t('portal.traffic.legend_tx') }} - - - - - -

-
-
- {{ t('portal.traffic.total') }}: - {{ t('portal.traffic.avg_day') }}: - {{ t('portal.traffic.peak') }}: -
+ {% if widgets.midea %} + +
+

{{ t('portal.midea.title') }}

+
+
{% endif %} - {% if widgets.services %} - -
-

{{ t('portal.services.title') }}{{ t('portal.services.sub') }}

-
+ {% if widgets.smarthome %} + +
+

{{ t('portal.smarthome.title') }}

+
+ +
{% endif %} @@ -172,8 +161,10 @@
-
%
-
+
+ +
%
+
{{ t('portal.pihole.total') }}
{{ t('portal.pihole.blocked') }}
@@ -187,22 +178,31 @@
{% endif %} - {% if widgets.midea %} - -
-

{{ t('portal.midea.title') }}

-
- + {% if widgets.traffic %} + +
+

{{ t('portal.traffic.title') }} + {{ t('portal.traffic.legend_rx') }}{{ t('portal.traffic.legend_tx') }} + + + + + +

+
+
+ {{ t('portal.traffic.total') }}: + {{ t('portal.traffic.avg_day') }}: + {{ t('portal.traffic.peak') }}: +
{% endif %} - {% if widgets.smarthome %} - -
-

{{ t('portal.smarthome.title') }}

-
- - + {% if widgets.services %} + +
+

{{ t('portal.services.title') }}{{ t('portal.services.sub') }}

+
{% endif %} diff --git a/tests/portal_css_smoke.test.js b/tests/portal_css_smoke.test.js index 97368198..f95da45a 100644 --- a/tests/portal_css_smoke.test.js +++ b/tests/portal_css_smoke.test.js @@ -23,13 +23,12 @@ test('portal.css contains dark and light theme token blocks', async () => { assert.ok(res.text.includes('[data-theme="light"]'), 'missing light theme block'); }); -test('portal.css contains the TP2a per-device DNS-protection widget, not the old donut styling', async () => { +test('portal.css contains the TP2a per-device DNS-protection widget, with the enriched donut widget', async () => { const res = await supertest(app).get('/css/portal.css').expect(200); // TP2a re-adds the per-device Pi-hole widget (the "DNS-Schutz" card) deliberately. assert.ok(res.text.includes('.c-pihole'), 'missing .c-pihole — TP2a per-device DNS widget styles'); - // It uses a block-rate bar, NOT the old donut/pi-wrap implementation, which stays removed. - assert.ok(!res.text.includes('.donut'), 'found .donut — old Pi-hole donut styling must not return'); - assert.ok(!res.text.includes('.pi-wrap'), 'found .pi-wrap — old Pi-hole donut styling must not return'); + // Task 2 replaces the bar with a block-rate donut. + assert.ok(res.text.includes('.donut'), 'missing .donut — enriched Pi-hole donut styling'); }); test('portal.css contains JS-state rules (moved from portal.js inline injector for CSP safety)', async () => { diff --git a/tests/portal_redesign_layout.test.js b/tests/portal_redesign_layout.test.js new file mode 100644 index 00000000..af3df65f --- /dev/null +++ b/tests/portal_redesign_layout.test.js @@ -0,0 +1,62 @@ +'use strict'; +const fs = require('fs'); +const crypto = require('crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex'); +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const supertest = require('supertest'); +const { setup, teardown } = require('./helpers/setup'); + +let app; +beforeEach(async () => { await setup(); app = require('../src/app').createApp(); }); +afterEach(teardown); + +async function portalHtml() { + const res = await supertest(app).get('/portal').expect(200); + return res.text; +} + +test('status strip keeps .c-device class (portal.js hydrateDevice guard) + device IDs', async () => { + const h = await portalHtml(); + assert.match(h, /class="[^"]*\bstrip\b[^"]*"/, 'status .strip present'); + assert.match(h, /class="[^"]*\bc-device\b[^"]*"/, '.c-device class retained for JS guard'); + for (const id of ['deviceStatus', 'deviceAddress', 'deviceDns', 'deviceHandshake', 'deviceKv']) { + assert.ok(h.includes('id="' + id + '"'), 'kept id ' + id); + } +}); + +test('control-first order: Klima (c-midea) appears before Dienste (c-services)', async () => { + const h = await portalHtml(); + assert.ok(h.indexOf('c-midea') < h.indexOf('c-services'), 'Klima before Dienste'); + assert.ok(h.indexOf('c-smarthome') < h.indexOf('c-services'), 'SmartHome before Dienste'); +}); + +test('portal.css uses full-width wrapper 1560', async () => { + const res = await supertest(app).get('/css/portal.css').expect(200); + assert.ok(res.text.includes('max-width:1560px') || res.text.includes('max-width: 1560px'), '.wrap widened to 1560'); +}); + +test('pi-hole widget renders a donut svg with piDonut id and keeps scope IDs', async () => { + const h = await portalHtml(); + assert.match(h, /class="[^"]*\bdonut\b[^"]*"/, '.donut present'); + assert.ok(h.includes('id="piDonut"'), 'donut arc has id piDonut'); + for (const id of ['piPct', 'piTotal', 'piBlocked', 'piAllowed', 'piAllowedWrap', 'piOwnerExtra', 'piholeSeg']) { + assert.ok(h.includes('id="' + id + '"') || h.includes(id), 'kept ' + id); + } + assert.match(h, /class="[^"]*\bpihole-widget\b[^"]*"/, '.pihole-widget retained'); +}); + +test('smarthome renders toggle tiles + sensor row (css + a11y switch role)', async () => { + const res = await supertest(app).get('/css/portal.css').expect(200); + assert.ok(res.text.includes('.sh-tile'), '.sh-tile styling present'); + assert.ok(res.text.includes('.toggle'), '.toggle styling present'); + assert.ok(res.text.includes('.sh-sensors'), '.sh-sensors row present'); + const js = fs.readFileSync('public/js/portal.js', 'utf8'); + assert.ok(js.includes('role="switch"') || js.includes("role='switch'"), 'smart-home toggle exposes role=switch'); +}); + +test('services render as compact launcher (svc-strip + svc styling in css)', async () => { + const res = await supertest(app).get('/css/portal.css').expect(200); + assert.ok(res.text.includes('.svc-strip'), '.svc-strip grid present'); + assert.ok(res.text.includes('.svc{') || res.text.includes('.svc '), '.svc tile styling present'); +});