From bb736e82580cec15f711ec0a156832d688d88c28 Mon Sep 17 00:00:00 2001 From: Umut Kantarci Date: Wed, 14 Oct 2026 11:13:38 +0200 Subject: [PATCH] Make the registry search live Co-Authored-By: Claude Opus 5.5 --- docs/STATUS.md | 4 +- zentrale/README.md | 4 +- zentrale/app/Anwendung.php | 25 ++++-- zentrale/app/vorlagen/_liste.php | 66 +++++++++++++++ zentrale/app/vorlagen/rahmen.php | 3 +- zentrale/app/vorlagen/uebersicht.php | 72 +--------------- zentrale/public/app.css | 5 ++ zentrale/public/app.js | 118 +++++++++++++++++++++++++++ 8 files changed, 220 insertions(+), 77 deletions(-) create mode 100644 zentrale/app/vorlagen/_liste.php create mode 100644 zentrale/public/app.js diff --git a/docs/STATUS.md b/docs/STATUS.md index 2c429b1..4b8b316 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -8,7 +8,9 @@ **Geprüft:** Lokal mit 63 Demo-Installationen im Browser: Seite 2 von 3, Suche + Filter kombiniert (8 Treffer), kein Treffer, Seitenzahl außerhalb des Bereichs wird begrenzt, 375 px ohne Querscrollen. `php scripts/zentrale-check.php` grün. -**Nächster Schritt:** Umut lädt `zentrale/app/` und `zentrale/public/app.css` neu hoch (Paket `var/zentrale-upload.zip`); `config.php` und `var/` bleiben. +**Nachtrag, gleicher Tag – Live-Suche:** Umut: „Suche sollte Ajax-Livesearch sein, nicht Enter-Suche“. `zentrale/public/app.js` lädt beim Tippen (250 ms Pause) nur die Liste nach (`/?…&teil=liste`, Vorlage `_liste.php`), ebenso bei Klick auf Kacheln und Seitenzahlen; Adresse per `history`, Zurück-Taste funktioniert, Trefferzeile bleibt Statusmeldung für Screenreader, abgelaufene Sitzung fällt auf normales Laden zurück. Der Suchen-Knopf verschwindet mit JavaScript. CSP erlaubt jetzt `script-src 'self'` und `connect-src 'self'`. Nebenbei behoben: `.knopf` überschrieb das `hidden`-Attribut. Geprüft im Browser mit 57 Demo-Installationen: Tippen ohne Neuladen, Filter + Suche, Zurücksetzen, Seite 2, Zurück-Taste, 375 px, keine Konsolenfehler. Die lokale Demo-Datenbank ist wieder gelöscht. + +**Nächster Schritt:** Umut lädt `zentrale/app/` und `zentrale/public/` neu hoch (Paket `var/zentrale-upload.zip`); `config.php` und `var/` auf dem Server bleiben unangetastet. ## Zentrale sites.kant-it.de: Meldung und Update aus der Ferne (14.10.2026) diff --git a/zentrale/README.md b/zentrale/README.md index a51411d..ef15126 100644 --- a/zentrale/README.md +++ b/zentrale/README.md @@ -41,7 +41,9 @@ Zugangsdaten. Die Zentrale speichert zusätzlich die IP-Adresse der letzten Meld Die Zählerkacheln oben sind zugleich Filter (aktuell, veraltet, verstummt, ohne Kunde). Die Suche findet Domain, frühere Domain, Kunde/Notiz, Fassung und IP-Adresse; Suche und Filter lassen sich -kombinieren. 25 Installationen je Seite, Domains natürlich sortiert und ohne „www.“. +kombinieren. Die Suche filtert schon beim Tippen; Kacheln und Seitenzahlen wechseln nur die +Liste (`public/app.js`, ohne JavaScript funktioniert alles als normales Formular). 25 Installationen +je Seite, Domains natürlich sortiert und ohne „www.“. ## Update aus der Ferne diff --git a/zentrale/app/Anwendung.php b/zentrale/app/Anwendung.php index 66a01f5..73dea7d 100644 --- a/zentrale/app/Anwendung.php +++ b/zentrale/app/Anwendung.php @@ -154,8 +154,18 @@ final class Anwendung $seiten = max(1, (int) ceil(count($treffer) / $proSeite)); $seite = min($seiten, max(1, (int) ($_GET['seite'] ?? 1))); - $this->zeigen('uebersicht', [ + $namen = ['aktuell' => 'aktuell', 'veraltet' => 'veraltet', 'still' => 'ohne Meldung seit 48 h', 'unbekannt' => 'ohne Kundenzuordnung']; + $von = [] === $treffer ? 0 : ($seite - 1) * $proSeite + 1; + $bis = min(count($treffer), $seite * $proSeite); + $trefferText = [] === $treffer ? 'Keine Treffer.' : sprintf('%d–%d von %d %s', $von, $bis, count($treffer), 1 === count($treffer) ? 'Installation' : 'Installationen') + .(null !== $gewaehlt ? ' · Filter: '.$namen[$gewaehlt] : '') + .('' !== $suche ? ' · Suche: „'.$suche.'“' : ''); + + // Die Live-Suche holt nur die Liste; ohne JavaScript kommt die ganze Seite. + $teil = 'liste' === ($_GET['teil'] ?? null); + $this->zeigen($teil ? '_liste' : 'uebersicht', [ 'titel' => 'Installationen', + 'trefferText' => $trefferText, 'neueste' => $neueste, 'installationen' => array_slice($treffer, ($seite - 1) * $proSeite, $proSeite), 'treffer' => count($treffer), @@ -163,12 +173,10 @@ final class Anwendung 'filter' => $gewaehlt, 'seite' => $seite, 'seiten' => $seiten, - 'von' => [] === $treffer ? 0 : ($seite - 1) * $proSeite + 1, - 'bis' => min(count($treffer), $seite * $proSeite), 'zahlen' => $zahlen, // Verstummte bleiben außen vor: Sie holen den Auftrag womöglich nie ab. Wartende haben schon einen. 'aktualisierbar' => array_values(array_filter($alle, static fn (array $i): bool => $i['kann_update'] && !$i['still'] && 'wartet' !== ($i['auftrag_zustand']['art'] ?? null))), - ]); + ], true, $teil); } private function detail(Bestand $bestand, Fassungen $fassungen): void @@ -353,7 +361,7 @@ final class Anwendung private function kopfzeilen(): void { - header("Content-Security-Policy: default-src 'none'; style-src 'self'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'; base-uri 'none'"); + header("Content-Security-Policy: default-src 'none'; script-src 'self'; connect-src 'self'; style-src 'self'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'; base-uri 'none'"); header('X-Content-Type-Options: nosniff'); header('Referrer-Policy: no-referrer'); header('X-Robots-Tag: noindex, nofollow'); @@ -361,9 +369,14 @@ final class Anwendung } /** @param array $daten */ - private function zeigen(string $vorlage, array $daten, bool $angemeldet = true): void + private function zeigen(string $vorlage, array $daten, bool $angemeldet = true, bool $ohneRahmen = false): void { header('Content-Type: text/html; charset=UTF-8'); + if ($ohneRahmen) { + echo (new Ansicht((string) ($_SESSION['csrf'] ?? '')))->vorlage($this->wurzel.'/app/vorlagen/'.$vorlage.'.php', $daten); + + return; + } $hinweis = $_SESSION['hinweis'] ?? null; unset($_SESSION['hinweis']); $v = new Ansicht((string) ($_SESSION['csrf'] ?? '')); diff --git a/zentrale/app/vorlagen/_liste.php b/zentrale/app/vorlagen/_liste.php new file mode 100644 index 0000000..ef38e3e --- /dev/null +++ b/zentrale/app/vorlagen/_liste.php @@ -0,0 +1,66 @@ + +
+ +
+

Noch keine Meldung

+

Sobald eine KantSites-Installation ab Fassung 1.36.0 läuft, meldet sie sich hier – spätestens 15 Minuten nach dem ersten Besuch oder Cron-Aufruf.

+
+ +
+

Nichts gefunden

+

Keine Installation passt zu Suche und Filter. Alle anzeigen

+
+ +
+ + + + + + + + + + + + + + + + + +
Gemeldete Installationen, Seite von
DomainKundeFassungLetzte MeldungZustandAktion
e($i['domain']) ?> + vorher e($i['domain_vorher']) ?>Unbekannt' : $v->e($i['notiz']) ?>e($i['fassung']) ?> Gite($v->vor($i['alter'])) ?> + Wartung + e($i['auftrag_zustand']['text']) ?>– + + +
+ csrf() ?> + + +
+ +
+
+ 1) { ?> + + + +
diff --git a/zentrale/app/vorlagen/rahmen.php b/zentrale/app/vorlagen/rahmen.php index 578e34b..2d5715d 100644 --- a/zentrale/app/vorlagen/rahmen.php +++ b/zentrale/app/vorlagen/rahmen.php @@ -6,7 +6,8 @@ <?= $v->e($titel) ?> · KantSites Zentrale - + + diff --git a/zentrale/app/vorlagen/uebersicht.php b/zentrale/app/vorlagen/uebersicht.php index c8b17ed..72c9ce6 100644 --- a/zentrale/app/vorlagen/uebersicht.php +++ b/zentrale/app/vorlagen/uebersicht.php @@ -43,76 +43,12 @@ $kacheln = [
- + - Zurücksetzen + >Zurücksetzen
-

- Keine Treffer.– von e($kacheln[$filter][1]) : '' ?>e($suche).'“' : '' ?> -

+

e($trefferText) ?>

- -
-

Noch keine Meldung

-

Sobald eine KantSites-Installation ab Fassung 1.36.0 läuft, meldet sie sich hier – spätestens 15 Minuten nach dem ersten Besuch oder Cron-Aufruf.

-
- -
-

Nichts gefunden

-

Keine Installation passt zu Suche und Filter. Alle anzeigen

-
- -
- - - - - - - - - - - - - - - - - -
Gemeldete Installationen, Seite von
DomainKundeFassungLetzte MeldungZustandAktion
e($i['domain']) ?> - vorher e($i['domain_vorher']) ?>Unbekannt' : $v->e($i['notiz']) ?>e($i['fassung']) ?> Gite($v->vor($i['alter'])) ?> - Wartung - e($i['auftrag_zustand']['text']) ?>– - - -
- csrf() ?> - - -
- -
-
- 1) { ?> - - - + diff --git a/zentrale/public/app.css b/zentrale/public/app.css index 60048fe..9a10d6f 100644 --- a/zentrale/public/app.css +++ b/zentrale/public/app.css @@ -21,6 +21,8 @@ * { box-sizing: border-box; } +[hidden] { display: none !important; } + html { -webkit-text-size-adjust: 100%; } body { @@ -176,3 +178,6 @@ input[type="text"]:focus-visible, input[type="password"]:focus-visible { border- .fakten { grid-template-columns: 1fr; gap: 0; } .fakten dd { margin-bottom: 8px; } } + +#liste:focus { outline: none; } +#liste[aria-busy="true"] { opacity: .6; transition: opacity .15s; } diff --git a/zentrale/public/app.js b/zentrale/public/app.js new file mode 100644 index 0000000..0d562bb --- /dev/null +++ b/zentrale/public/app.js @@ -0,0 +1,118 @@ +// Live-Suche der Übersicht: Tippen, Filter und Seitenwechsel laden nur die Liste nach. +// Ohne JavaScript bleibt alles ein gewöhnliches Formular mit Links. +(() => { + const form = document.querySelector('form.suche'); + const liste = document.getElementById('liste'); + if (!form || !liste || !window.fetch || !window.URL) { + return; + } + const feld = form.querySelector('#q'); + const status = document.getElementById('treffer'); + const zuruecksetzen = form.querySelector('.zuruecksetzen'); + const knopf = form.querySelector('button[type="submit"]'); + const filterFeld = () => form.querySelector('input[name="filter"]'); + knopf.hidden = true; + + let warten = 0; + let laufend = null; + + /** Die Adresse aus Suchfeld und Filter, ohne leere Werte. */ + const adresseAusFormular = () => { + const url = new URL(form.action, location.href); + const q = feld.value.trim(); + if (q !== '') url.searchParams.set('q', q); + const filter = filterFeld(); + if (filter && filter.value !== '') url.searchParams.set('filter', filter.value); + return url; + }; + + /** Kacheln und Filterfeld an Suche und Filter der neuen Adresse angleichen. */ + const umgebungAnpassen = (url) => { + const q = url.searchParams.get('q') || ''; + const filter = url.searchParams.get('filter') || ''; + document.querySelectorAll('.zahlen a').forEach((a) => { + const ziel = new URL(a.href, location.href); + if (q === '') ziel.searchParams.delete('q'); else ziel.searchParams.set('q', q); + ziel.searchParams.delete('seite'); + a.href = ziel.pathname + ziel.search; + const gewaehlt = (ziel.searchParams.get('filter') || '') === filter; + a.parentElement.classList.toggle('gewaehlt', gewaehlt); + if (gewaehlt) a.setAttribute('aria-current', 'true'); else a.removeAttribute('aria-current'); + }); + let feldFilter = filterFeld(); + if (filter !== '' && !feldFilter) { + feldFilter = document.createElement('input'); + feldFilter.type = 'hidden'; + feldFilter.name = 'filter'; + form.prepend(feldFilter); + } + if (feldFilter) { + if (filter === '') feldFilter.remove(); else feldFilter.value = filter; + } + if (feld.value.trim() !== q) feld.value = q; + zuruecksetzen.hidden = q === '' && filter === ''; + }; + + const laden = (url, verlauf) => { + if (laufend) laufend.abort(); + laufend = new AbortController(); + const abruf = new URL(url, location.href); + abruf.searchParams.set('teil', 'liste'); + liste.setAttribute('aria-busy', 'true'); + fetch(abruf, { signal: laufend.signal, credentials: 'same-origin', headers: { Accept: 'text/html' } }) + .then((antwort) => { + // Abgelaufene Sitzung: Die Antwort ist die Anmeldeseite, nicht die Liste. + if (!antwort.ok || antwort.redirected) throw new Error('voll'); + return antwort.text(); + }) + .then((html) => { + const neu = new DOMParser().parseFromString(html, 'text/html').getElementById('liste'); + if (!neu) throw new Error('voll'); + liste.replaceChildren(...neu.childNodes); + liste.dataset.treffer = neu.dataset.treffer || ''; + status.textContent = liste.dataset.treffer; + const sichtbar = new URL(url, location.href); + if (verlauf === 'push') history.pushState(null, '', sichtbar.pathname + sichtbar.search); + else if (verlauf === 'replace') history.replaceState(null, '', sichtbar.pathname + sichtbar.search); + umgebungAnpassen(sichtbar); + }) + .catch((fehler) => { + if (fehler.name !== 'AbortError') location.href = url.toString(); + }) + .finally(() => liste.removeAttribute('aria-busy')); + }; + + feld.addEventListener('input', () => { + clearTimeout(warten); + warten = setTimeout(() => laden(adresseAusFormular(), 'replace'), 250); + }); + + form.addEventListener('submit', (ereignis) => { + ereignis.preventDefault(); + clearTimeout(warten); + laden(adresseAusFormular(), 'replace'); + }); + + zuruecksetzen.addEventListener('click', (ereignis) => { + ereignis.preventDefault(); + feld.value = ''; + const filter = filterFeld(); + if (filter) filter.remove(); + laden(new URL('/', location.href), 'push'); + feld.focus(); + }); + + // Filterkacheln und Seitenzahlen: nur die Liste wechseln. + document.addEventListener('click', (ereignis) => { + const link = ereignis.target.closest('.zahlen a, #liste .seiten a'); + if (!link || ereignis.ctrlKey || ereignis.metaKey || ereignis.shiftKey || ereignis.button !== 0) return; + ereignis.preventDefault(); + laden(new URL(link.href, location.href), 'push'); + if (link.closest('.seiten')) { + form.scrollIntoView({ block: 'start' }); + liste.focus({ preventScroll: true }); + } + }); + + window.addEventListener('popstate', () => laden(new URL(location.href), null)); +})();