From aed664dc16ef15b58dc69311871b9eff0fd99fda Mon Sep 17 00:00:00 2001 From: Jeuner <62662523+Jeuners@users.noreply.github.com> Date: Wed, 3 Jun 2026 22:38:02 +0200 Subject: [PATCH] UI-Redesign: App-Anmutung + adaptiv hell/dunkel + WCAG-AAA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - App-Shell: sticky App-Bar mit Wortmarke, Orts-Chip (spiegelt PLZ), ⚙-Einstieg in die LLM-Konfig; Karten mit Tiefe statt Hairline-Doku-Look - Adaptives Theme via prefers-color-scheme (hell + dunkel), color-scheme- Meta + theme-color je Theme - AAA-Kontrast: beide Paletten auf >=7:1 ausgelegt und mit axe-core verifiziert (0 Verletzungen, inkl. color-contrast-enhanced, 30 Regeln ok) - A11y-Semantik: Kategorie-Chip & Gruppen-Header sind echte +
+

Ortskonkret · händlerübergreifend · belegt

+

Angebots-Übersicht

+

Zwei strikt getrennte Stufen: zuerst die Rohdaten deterministisch holen + und speichern (kein LLM, kein Key), danach erst per LLM in Produktgruppen + einordnen. Jedes Angebot ist belegt — kein Auffüllen, Unsicheres ist markiert.

+
+
-
1
+

Rohdaten holen & speichern

Deterministischer Abruf für eine PLZ. Wird pro PLZ/Woche auf Platte gespeichert.

@@ -275,23 +374,23 @@
- +
- - + +
-
+
- +
- - + + LLM-Konfiguration gilt für Stufe 2 @@ -314,8 +413,8 @@
- - + +
@@ -331,7 +430,7 @@
-
2
+

Kategorisieren

Ordnet die gespeicherten Rohdaten per LLM in Produktgruppen. Verändert keine Angebotsdaten.

@@ -341,12 +440,12 @@
- - + +
🔒 Erst aktiv, sobald für die PLZ Rohdaten gespeichert sind (Stufe 1).
-
+
@@ -361,8 +460,8 @@
- -
+ +
@@ -375,8 +474,6 @@ let DATEN = null; // kategorisiertes Ergebnis (Stufe 2) let ROHDA = false; // ob für die aktuelle PLZ Rohdaten vorliegen // Pfad-Basis: lokal "/", hinter einem Reverse-Proxy z.B. "/angebote/". -// Macht die API-Aufrufe pfad-agnostisch -- absolute /api/-Pfade würden unter -// einem Unterpfad sonst ins Leere zeigen (404). const BASE = location.pathname.endsWith("/") ? location.pathname : location.pathname.replace(/[^/]*$/, ""); @@ -387,11 +484,17 @@ function preisFmt(p){ return p!=null ? p.toLocaleString("de-DE",{minimumFraction function setRohStatus(html){ $("#rohstatus").innerHTML = html; } function setStatus(html){ $("#status").innerHTML = html; } +// App-Bar: aktuelle PLZ spiegeln (gibt App-Gefühl + Orientierung) +function setAppbarPlz(){ + const v = ($("#plz").value || "").trim(); + const el = $("#appbarPlz"); + if (/^\d{5}$/.test(v)){ el.textContent = "📍 " + v; el.classList.remove("leer"); } + else { el.textContent = "PLZ wählen"; el.classList.add("leer"); } +} + // ----- Modelle (LLM-Konfig: OpenRouter ODER Ollama) ------------------------- function anbieter(){ return $("#anbieter").value; } -// Auswahl in localStorage merken/laden, damit Anbieter + Modell einen Reload -// überleben (statt auf den Default zurückzuspringen). function merkeWahl(){ try { localStorage.setItem("ang_anbieter", anbieter()); @@ -402,8 +505,6 @@ function merkeWahl(){ function gemerkterAnbieter(){ try { return localStorage.getItem("ang_anbieter"); } catch(e){ return null; } } function gemerktesModell(ab){ try { return localStorage.getItem("ang_modell_" + ab); } catch(e){ return null; } } -// Hält das dauerhaft sichtbare "gewähltes Modell"-Chip im Konfig-Kopf aktuell -- -// auch wenn das Panel zugeklappt ist -- und merkt die Wahl. function setGewaehlt(){ const sel = $("#modell"); const opt = sel.options[sel.selectedIndex]; @@ -416,7 +517,6 @@ function setGewaehlt(){ async function ladeModelle(q=""){ const sel = $("#modell"); const ab = anbieter(); - // Anbieter-spezifische UI: Ollama braucht weder Key noch Suche. $("#keyzeile").style.display = ab === "ollama" ? "none" : ""; $("#suchfeld").style.display = ab === "ollama" ? "none" : ""; $("#konfighint").innerHTML = ab === "ollama" @@ -449,8 +549,6 @@ async function ladeModelle(q=""){ if (!m.tools) o.disabled = true; sel.appendChild(o); } - // Vorauswahl: gemerktes Modell -- aber bei OpenRouter NICHT automatisch ein - // gedrosseltes Free-Modell (führt zu 429); dann lieber das empfohlene. const opts = [...sel.options]; const gemerkt = gemerktesModell(ab); const gemerktOpt = gemerkt ? opts.find(o => o.value === gemerkt && !o.disabled) : null; @@ -520,7 +618,6 @@ async function pruefeRohstand(plz){ if (!/^\d{5}$/.test(plz)) return; try { const r = await fetch(api("/api/rohdaten/" + encodeURIComponent(plz))); - // Stale-Guard: zwischenzeitlich getippte/andere PLZ -> Antwort verwerfen. if ($("#plz").value.trim() !== plz) return; if (r.status === 404) { lockStage2("Noch keine Rohdaten für diese PLZ — Stufe 1 ausführen."); return; } if (!r.ok) return; @@ -554,12 +651,11 @@ async function holeRohdaten(){ } // ----- Stufe 2: Kategorisieren ---------------------------------------------- -// Sichtbarer LLM-Arbeits-Indikator (Grundregel: zeig, dass das Modell arbeitet) function llmBusy(titel, done, total, modell){ const indet = !total; const pct = indet ? 0 : Math.round(100 * done / total); - return `
-
+ return `
+
🤖 ${esc(titel)}${total ? ` · Batch ${done}/${total}` : ""} ${modell ? "Modell: " + esc(modell) : "das Sprachmodell ordnet die Angebote ein …"} @@ -632,7 +728,7 @@ function render(){ fh.innerHTML = "" + d.haendler.map(h => ``).join(""); fh.value = aktuell; - zeichneGruppen(); // baut Sektionen + füllt das Gruppen-Band mit den Counts + zeichneGruppen(); $("#footer").innerHTML = `Beobachtete Händler (belegt): ${d.haendler.map(esc).join(", ")}.` + @@ -659,10 +755,25 @@ function zeichneGruppen(){ const sec = document.createElement("section"); sec.className = "grp"; sec.dataset.grp = g.name; + const secId = "grp-" + g.name.replace(/[^a-z0-9]+/gi, "-").toLowerCase(); + sec.id = secId; + const h = document.createElement("h3"); - h.innerHTML = `${esc(g.name)}${items.length}`; - h.onclick = () => sec.classList.toggle("zu"); + h.className = "grp-h"; + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "grp-h-btn"; + btn.setAttribute("aria-expanded", "true"); + btn.innerHTML = + `${esc(g.name)}` + + `${items.length}`; + btn.onclick = () => { + const zu = sec.classList.toggle("zu"); + btn.setAttribute("aria-expanded", zu ? "false" : "true"); + }; + h.appendChild(btn); sec.appendChild(h); + if (!items.length){ const p = document.createElement("div"); p.className = "leer"; p.textContent = (fH || fT || nurSicher) ? "keine Treffer im Filter" : "keine Angebote"; @@ -684,6 +795,7 @@ function baueGruppenband(navDaten){ if (!navDaten.length){ nav.innerHTML = `keine Treffer`; return; } for (const g of navDaten){ const b = document.createElement("button"); + b.type = "button"; b.className = "navitem"; b.dataset.grp = g.name; const uns = g.uns ? ` ${g.uns}` : ""; b.innerHTML = `${esc(g.name)}${g.count}${uns}`; @@ -697,8 +809,17 @@ function springeZu(name){ for (const sec of document.querySelectorAll("#gruppen section.grp")){ if (sec.dataset.grp === name){ ziel = sec; break; } } - if (ziel){ ziel.classList.remove("zu"); ziel.scrollIntoView({behavior:"smooth", block:"start"}); } - for (const b of document.querySelectorAll(".navitem")) b.classList.toggle("aktiv", b.dataset.grp === name); + if (ziel){ + ziel.classList.remove("zu"); + const btn = ziel.querySelector(".grp-h-btn"); + if (btn) btn.setAttribute("aria-expanded", "true"); + ziel.scrollIntoView({behavior:"smooth", block:"start"}); + } + for (const b of document.querySelectorAll(".navitem")){ + const aktiv = b.dataset.grp === name; + b.classList.toggle("aktiv", aktiv); + if (aktiv) b.setAttribute("aria-current", "true"); else b.removeAttribute("aria-current"); + } } function zeile(a, gruppe){ @@ -709,11 +830,15 @@ function zeile(a, gruppe){ const gueltig = (a.gueltig_von || a.gueltig_bis) ? ` · gültig ${a.gueltig_von||"?"}–${a.gueltig_bis||"?"}` : ""; const badge = a.unsicher ? `unsicher` : ""; + // Quelle: sichtbar lassen, aber als ruhiges Badge mit Quellen-Namen (statt Roh-URL) + const qName = (a.quelle||"").split(/[:/?#]/)[0] || "Quelle"; + const quelleBadge = a.quelle + ? `🔗 ${esc(qName)}` : ""; li.innerHTML = - `
${marke}${esc(a.titel)}${badge}${esc(gruppe)}
` + + `
${marke}${esc(a.titel)}${badge}` + + `
` + `
${preisFmt(a.preis)}
` + - `
${esc(a.haendler)}${menge}${gueltig} -  ${esc((a.quelle||"").slice(0,42))}
` + + `
${esc(a.haendler)}${menge}${gueltig} ${quelleBadge}
` + `
${a.grundpreis ? esc(a.grundpreis) : ""}
`; const chip = li.querySelector(".gchip"); chip.onclick = () => oeffneKorrektur(chip, a, gruppe); @@ -724,6 +849,7 @@ function zeile(a, gruppe){ function oeffneKorrektur(chip, a, vonGruppe){ const sel = document.createElement("select"); sel.className = "ksel"; + sel.setAttribute("aria-label", "Neue Produktgruppe wählen"); for (const g of DATEN.gruppen.map(x => x.name)){ const o = document.createElement("option"); o.value = g; o.textContent = g; @@ -762,7 +888,7 @@ async function korrigiere(a, vonGruppe, zielGruppe){ function zeigeFeedback(txt, fehler){ const el = $("#korrektur-feedback"); el.textContent = txt; - el.style.color = fehler ? "var(--err)" : "var(--brand-d)"; + el.style.color = fehler ? "var(--err)" : "var(--brand-text)"; el.classList.add("an"); clearTimeout(zeigeFeedback._t); zeigeFeedback._t = setTimeout(() => el.classList.remove("an"), 2800); @@ -773,11 +899,17 @@ $("#rohholen").onclick = holeRohdaten; $("#kategorisieren").onclick = kategorisiere; $("#refresh").onclick = () => ladeModelle($("#modellsuche").value.trim()); $("#modellsuche").addEventListener("keydown", e => { if (e.key==="Enter") ladeModelle(e.target.value.trim()); }); -$("#anbieter").addEventListener("change", () => ladeModelle()); // Anbieter umschalten -> Liste neu -$("#modell").addEventListener("change", setGewaehlt); // Auswahl -> sichtbares Chip aktualisieren +$("#anbieter").addEventListener("change", () => ladeModelle()); +$("#modell").addEventListener("change", setGewaehlt); $("#plz").addEventListener("keydown", e => { if (e.key==="Enter") holeRohdaten(); }); +$("#appbarSettings").onclick = () => { + const c = $("#config"); c.open = true; + c.scrollIntoView({behavior:"smooth", block:"center"}); + c.querySelector("summary").focus(); +}; let _plzTimer; $("#plz").addEventListener("input", () => { + setAppbarPlz(); clearTimeout(_plzTimer); const plz = $("#plz").value.trim(); _plzTimer = setTimeout(() => pruefeRohstand(plz), 350); @@ -786,25 +918,24 @@ for (const id of ["#fhaendler","#ftext","#fsicher"]) $(id).addEventListener("input", () => DATEN && zeichneGruppen()); // ----- Init ------------------------------------------------------------------ -// Bookmarkbar: ?plz=… [&anbieter=…] [&modell=…] [&auto=1] (async function init(){ const _p = new URLSearchParams(location.search); - // Anbieter: URL-Param hat Vorrang, sonst die gemerkte Wahl, sonst Default. const ab = _p.get("anbieter") || gemerkterAnbieter(); if (ab) $("#anbieter").value = ab; await ladeModelle(); const m = _p.get("modell"); if (m){ const sel = $("#modell"); - if (![...sel.options].some(o => o.value === m)){ // auch nicht-gelistetes zulassen + if (![...sel.options].some(o => o.value === m)){ const o = document.createElement("option"); o.value = o.textContent = m; sel.insertBefore(o, sel.firstChild); } sel.value = m; setGewaehlt(); } if (_p.get("plz")) $("#plz").value = _p.get("plz").trim(); + setAppbarPlz(); await pruefeRohstand($("#plz").value.trim()); - if (_p.get("auto") && ROHDA) kategorisiere(); // Stufe 2 automatisch (wenn Rohdaten da) + if (_p.get("auto") && ROHDA) kategorisiere(); })();