diff --git a/pruef-verwaltung-kacheln.mjs b/pruef-verwaltung-kacheln.mjs new file mode 100644 index 00000000..01e5f33c --- /dev/null +++ b/pruef-verwaltung-kacheln.mjs @@ -0,0 +1,194 @@ +/* ===================================================================== + Verwaltung: Stimmen-Kacheln + Supporter-Liste ein-/ausklappbar + (Nutzer-Wunsch 27.08.2026) + + Geprüft wird mit nachgebildeten Daten, weil die Verwaltung hinter der + Zugangswand liegt: admin-auth.js wird durch eine Attrappe ersetzt, die + eine gültige Sitzung und feste Antworten liefert. Absichtlich lange + Namen/Texte — genau daran brechen Kachel-Layouts. + + Gemessen wird: + 1. Stimmen: im zugeklappten Zustand ist genau die erste Reihe + sichtbar (Anzahl = Spaltenzahl des Rasters), am Handy also 1. + 2. Knopf klappt auf (alle sichtbar) und wieder zu (wieder eine Reihe). + 3. Supporter: nur die 4 zuletzt Angemeldeten, Knopf zeigt alle, Knopf + klappt wieder zu. + 4. Reihenfolge stimmt: die 4 sichtbaren sind die neuesten. + 5. Nichts läuft am Handy seitlich über. + ===================================================================== */ +import { chromium } from "playwright"; +import http from "http"; +import fs from "fs"; +import path from "path"; + +const TYP = { + ".html": "text/html", ".css": "text/css", ".js": "text/javascript", + ".png": "image/png", ".svg": "image/svg+xml", ".webp": "image/webp", + ".json": "application/json", ".ico": "image/x-icon", ".jpg": "image/jpeg", + ".webmanifest": "application/manifest+json", +}; + +const srv = http.createServer((q, r) => { + const p = path.join(process.cwd(), decodeURIComponent(q.url.split("?")[0])); + if (fs.existsSync(p) && fs.statSync(p).isFile()) { + r.writeHead(200, { "content-type": TYP[path.extname(p)] || "text/plain" }); + return r.end(fs.readFileSync(p)); + } + r.writeHead(404); + r.end(""); +}); + +let ok = 0, fehl = 0; +const funde = []; +const pruefe = (name, gut, info) => { + if (gut) { ok++; console.log(" ok " + name + (info ? " -> " + info : "")); } + else { fehl++; console.log(" FEHL " + name + (info ? " -> " + info : "")); funde.push(name + " — " + info); } +}; + +const vorTagen = (n) => new Date(Date.now() - n * 864e5).toISOString(); + +const STIMMEN = [ + "Team Dogi bedeutet für mich Zusammenhalt, echte Freundschaft und Menschen auf die man sich verlassen kann. In erster Linie bin ich sehr stolz und auch gleichzeitig dankbar das Dogi weiter gemacht hat.", + "Team Dogi bedeutet für uns Zusammenhalt, echte Freundschaft und Menschen, auf die man sich verlassen kann. Hinter all dem steht für uns vor allem ein ganz besonderer Mensch, Dogi.", + "Kurz und knapp: beste Community.", + "Ich bin seit dem ersten Tag dabei und habe hier Freunde fürs Leben gefunden — danke an alle, die das jeden Tag mittragen.", + "Ein Wahnsinns-Team mit einem Riesenherz. Jede Woche neue Aktionen, immer fair, immer ehrlich.", + "Danke für alles!", + "Hier fühlt man sich sofort willkommen, egal ob man 5 Minuten oder 5 Monate dabei ist.", +]; + +const TESTIMONIALS = STIMMEN.map((nachricht, i) => ({ + id: "t" + i, + name: ["Sabrina-Charlotte Winterfeld-Baumgartner", "Klabine", "Ghost", "DjBeatim", "VanVan", "Miss", "Marina"][i], + tiktokUsername: ["sabrina_winterfeld_offiziell", "klabine", "_sven_85", "mairetim", "o.o.vanessa.o.o", "seelen_staub", "flauschirexi"][i], + nachricht, + status: i < 2 ? "freigegeben" : i === 2 ? "eingereicht" : "freigegeben", + avatar: null, + createdAt: vorTagen(i + 1), +})); + +/* 9 Supporter, absteigend nach Anmeldedatum: s0 ist der neueste. */ +const SUPPORTER = Array.from({ length: 9 }, (_, i) => ({ + id: "s" + i, + display_name: ["Marina", "Miss", "Diene", "Ghost", "DjBeatim", "VanVan", "Dogi", "DogFather", "Immobilienverwaltung Sonnenhof"][i], + tiktok_username: ["flauschirexi", "seelen_staub", "_ditschi", "_sven_85", "mairetim", "o.o.vanessa.o.o", "dogfather0804", "dogfather0804", "sonnenhof_immobilienverwaltung"][i], + email: ["marina83124@gmail.com", "hamburg_tatjana@live.de", "diene0175@hotmail.de", "kaltenbornsven@hotmail.com", "timmaire0@gmail.com", "v.hettmann@gmail.com", "qcigano@gmail.com", "dogfather1608@gmail.com", "kontakt.projektanfragen@immobilienverwaltung-sonnenhof.example.com"][i], + sub_status: "none", + total_paid_months: 0, + created_at: vorTagen(i), + progress: { cycleNumber: 1, currentCycleMonth: 0 }, +})); + +const ATTRAPPE = ` + const SESSION = { name: "Dogi", isOwner: true, permissions: ["*"], token: "test" }; + const TESTIMONIALS = ${JSON.stringify(TESTIMONIALS)}; + const SUPPORTER = ${JSON.stringify(SUPPORTER)}; + window.AdminAuth = { + getSession: () => SESSION, + setSession() {}, clearSession() {}, + sessionHasPermission: () => true, + async apiCall(base, pfad) { + if (pfad === "/admin/testimonials/list") return { ok: true, testimonials: TESTIMONIALS }; + if (pfad === "/admin/supporter/list") return { ok: true, supporters: SUPPORTER, supporter: SUPPORTER }; + if (pfad === "/admin/overview") return { ok: true, premiums: [], payments: [] }; + if (pfad === "/admin/events/list") return { ok: true, events: [null, null] }; + if (pfad === "/admin/polls/list") return { ok: true, polls: [] }; + if (pfad === "/admin/team/list") return { ok: true, members: [], team: [] }; + if (pfad === "/admin/special-event") return { ok: true, event: null }; + return { ok: true }; + }, + async doLogin() { return { ok: true }; }, + async doLogout() {}, + }; +`; + +const zustand = (seite) => seite.evaluate(() => { + const grid = document.getElementById("testimonials-liste"); + const karten = Array.from(grid.querySelectorAll("[data-testimonial-id]")); + const sichtbar = (el) => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length); + const spur = getComputedStyle(grid).gridTemplateColumns; + return { + spalten: spur && spur !== "none" ? spur.split(" ").filter(Boolean).length : 1, + stimmenGesamt: karten.length, + stimmenSichtbar: karten.filter(sichtbar).length, + stimmenKnopf: (() => { const b = document.getElementById("stimmen-mehr-btn"); return { da: !b.hidden, text: b.textContent.trim() }; })(), + zeilen: Array.from(document.querySelectorAll("#supporter-tbody tr[data-id]")).map((tr) => tr.querySelector(".vw-name-cell")?.textContent || ""), + supporterKnopf: (() => { const b = document.getElementById("supporter-mehr-btn"); return { da: !b.hidden, text: b.textContent.trim() }; })(), + ueberlauf: document.documentElement.scrollWidth - document.documentElement.clientWidth, + }; +}); + +async function laden(browser, viewport, dateiname) { + const seite = await browser.newPage({ viewport }); + await seite.route("**/assets/js/admin-auth.js*", (route) => + route.fulfill({ status: 200, contentType: "text/javascript", body: ATTRAPPE }) + ); + seite.on("pageerror", (e) => pruefe("Kein JS-Fehler (" + dateiname + ")", false, (e.stack || e.message).split("\n").slice(0, 4).join(" | "))); + await seite.goto("http://127.0.0.1:4422/verwaltung.html"); + await seite.waitForSelector("[data-testimonial-id]", { timeout: 8000 }); + await seite.waitForTimeout(400); + return seite; +} + +(async () => { + await new Promise((r) => srv.listen(4422, "127.0.0.1", r)); + const browser = await chromium.launch(); + + /* ---------- Computer ---------- */ + console.log("\n== Computer (1440x900) =="); + const pc = await laden(browser, { width: 1440, height: 900 }, "computer"); + let z = await zustand(pc); + console.log(" Raster-Spalten:", z.spalten); + pruefe("Stimmen stehen nebeneinander", z.spalten >= 2, z.spalten + " Kacheln pro Reihe"); + pruefe("Nur die erste Reihe sichtbar", z.stimmenSichtbar === z.spalten, z.stimmenSichtbar + " von " + z.stimmenGesamt + " sichtbar"); + pruefe("Aufklapp-Knopf da", z.stimmenKnopf.da, z.stimmenKnopf.text); + pruefe("Supporter: nur 4 Zeilen", z.zeilen.length === 4, z.zeilen.join(", ")); + pruefe("Supporter: die 4 neuesten", z.zeilen.join("|") === "Marina|Miss|Diene|Ghost", z.zeilen.join("|")); + pruefe("Supporter-Knopf da", z.supporterKnopf.da, z.supporterKnopf.text); + + await pc.screenshot({ path: "bild-kacheln-computer-zu.png", fullPage: true }); + + await pc.click("#stimmen-mehr-btn"); + await pc.click("#supporter-mehr-btn"); + await pc.waitForTimeout(300); + z = await zustand(pc); + pruefe("Aufgeklappt: alle Stimmen sichtbar", z.stimmenSichtbar === z.stimmenGesamt, z.stimmenSichtbar + "/" + z.stimmenGesamt); + pruefe("Aufgeklappt: alle Supporter sichtbar", z.zeilen.length === 9, z.zeilen.length + " Zeilen"); + pruefe("Knopftext wechselt (Stimmen)", /Weniger/.test(z.stimmenKnopf.text), z.stimmenKnopf.text); + pruefe("Knopftext wechselt (Supporter)", /Weniger/.test(z.supporterKnopf.text), z.supporterKnopf.text); + await pc.screenshot({ path: "bild-kacheln-computer-offen.png", fullPage: true }); + + await pc.click("#stimmen-mehr-btn"); + await pc.click("#supporter-mehr-btn"); + await pc.waitForTimeout(400); + z = await zustand(pc); + pruefe("Wieder zugeklappt: eine Reihe Stimmen", z.stimmenSichtbar === z.spalten, z.stimmenSichtbar + "/" + z.stimmenGesamt); + pruefe("Wieder zugeklappt: 4 Supporter", z.zeilen.length === 4, z.zeilen.length + " Zeilen"); + + /* ---------- Handy ---------- */ + console.log("\n== Handy (390x844) =="); + const handy = await laden(browser, { width: 390, height: 844 }, "handy"); + let h = await zustand(handy); + pruefe("Handy: eine Kachel pro Reihe", h.spalten === 1, h.spalten + " Spalte(n)"); + pruefe("Handy: nur die erste Kachel sichtbar", h.stimmenSichtbar === 1, h.stimmenSichtbar + " sichtbar"); + pruefe("Handy: nur 4 Supporter", h.zeilen.length === 4, h.zeilen.length + " Zeilen"); + pruefe("Handy: kein seitlicher Überlauf", h.ueberlauf <= 1, h.ueberlauf + "px"); + await handy.screenshot({ path: "bild-kacheln-handy-zu.png", fullPage: true }); + await handy.click("#stimmen-mehr-btn"); + await handy.waitForTimeout(250); + h = await zustand(handy); + pruefe("Handy: aufgeklappt alle Stimmen", h.stimmenSichtbar === h.stimmenGesamt, h.stimmenSichtbar + "/" + h.stimmenGesamt); + + /* ---------- Breitenwechsel im zugeklappten Zustand ---------- */ + console.log("\n== Fenster verkleinern (zugeklappt) =="); + await pc.setViewportSize({ width: 760, height: 900 }); + await pc.waitForTimeout(400); + z = await zustand(pc); + pruefe("Nach Größenänderung stimmt die erste Reihe", z.stimmenSichtbar === z.spalten, z.stimmenSichtbar + " sichtbar bei " + z.spalten + " Spalten"); + + await browser.close(); + srv.close(); + console.log("\n===== " + ok + " ok, " + fehl + " Fehler ====="); + if (funde.length) { console.log("\nOffene Punkte:"); funde.forEach((f) => console.log(" - " + f)); } + process.exit(fehl ? 1 : 0); +})(); diff --git a/verwaltung.html b/verwaltung.html index 15dea4bd..0e0090b0 100644 --- a/verwaltung.html +++ b/verwaltung.html @@ -78,6 +78,36 @@ .vw-stimme-upload { display: block; margin-top: .8rem; color: var(--text-muted); } .vw-stimme-upload input { display: block; margin-top: .3rem; } + /* ---------- Stimmen: kompakte Kacheln, nur erste Reihe (27.08.2026) ---------- + Nutzer-Wunsch: "die kacheln sollen kleiner sein so das auch paar neben + einander stehen, und ich soll immer nur die erste reihe sehen und auf + einen button drücken um die anderen zu sehen und wieder auf dern button + damit die liste wieder zu geht." + Wie viele Kacheln in eine Reihe passen, entscheidet die Bildschirmbreite + (auto-fill). Deshalb wird die Reihenbreite NICHT fest verdrahtet, sondern + die Anzeige-Logik liest die tatsächliche Spaltenzahl aus dem Raster aus — + so stimmt "erste Reihe" am Handy (1 Kachel) genauso wie am großen + Bildschirm (3-4 Kacheln). */ + .vw-stimmen-grid { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); align-items: start; } + .vw-stimmen-karte { margin: 0 !important; padding: 1rem 1.05rem 1.1rem !important; } + .vw-stimmen-karte .vw-stimme-kopf { display: flex; justify-content: space-between; align-items: flex-start; gap: .55rem; flex-wrap: wrap; } + .vw-stimmen-karte .btn-row { gap: .4rem; flex-wrap: wrap; } + .vw-stimmen-karte .vw-refresh-btn { padding: .4rem .75rem; font-size: .76rem; } + .vw-stimmen-karte .vw-stimme-text { margin: .75rem 0 0; font-size: .88rem; line-height: 1.55; } + .vw-stimmen-karte .vw-stimme-avatar-block { margin-top: .85rem; padding-top: .75rem; } + .vw-stimmen-karte .vw-stimme-avatar-wahl { grid-template-columns: repeat(auto-fill, minmax(40px, 1fr)); max-width: 100%; } + .vw-stimmen-karte .vw-stimme-upload { margin-top: .6rem; font-size: .76rem; } + + /* Ein-/Ausklappen: gilt für die Stimmen-Kacheln UND die Supporter-Liste. */ + .vw-eingeklappt { display: none !important; } + .vw-mehr-zeile { display: flex; justify-content: center; margin-top: 1.1rem; } + /* Der Knopftext ist lang ("Alle 7 Stimmen anzeigen (6 weitere)") — am Handy + muss er umbrechen dürfen, sonst schiebt er sich über den Rand hinaus. */ + .vw-mehr-btn { cursor: pointer; max-width: 100%; flex-wrap: wrap; justify-content: center; white-space: normal; text-align: center; line-height: 1.35; } + .vw-mehr-btn .pf { display: inline-block; transition: transform .18s ease; } + .vw-mehr-btn[aria-expanded="true"] .pf { transform: rotate(180deg); } + @media (prefers-reduced-motion: reduce) { .vw-mehr-btn .pf { transition: none; } } + #supp-root, #vw-stats-root, #session-bar { --accent: #7dd3fc; --accent-2: #38bdf8; --accent-contrast: #1a1408; } body { position: relative; } /* Nutzer-Wunsch 04.08.2026 (3. Runde): "alles bisschen größer, Schrift @@ -892,7 +922,10 @@
Einreichungen von der Stimmen-Seite. Erst nach "Freigeben" erscheinen sie öffentlich.
- + +${escapeHtml(t.nachricht)}
+${escapeHtml(t.nachricht)}
${avatarWahlHtml(t)}Stimmen konnten nicht geladen werden.
`; + stimmenReiheAnwenden(); return; } const liste = res.testimonials || []; listeEl.innerHTML = liste.length ? liste.map(testimonialKarteHtml).join("") : `Noch keine Einreichungen.
`; + stimmenReiheAnwenden(); const aktion = async (btn, status) => { const id = btn.closest("[data-testimonial-id]").dataset.testimonialId; @@ -2699,6 +2791,40 @@ .join(""); } + /* Standardmäßig stehen nur die 4 zuletzt Angemeldeten in der Liste, alles + Weitere kommt auf Knopfdruck (Nutzer-Wunsch 27.08.2026: "ich soll immer + nur die ersten 4 sehen also letzten angemeldeten sehen und auf einen + button drücken um die anderen zu sehen"). "Zuletzt angemeldet" ergibt + sich aus der Standard-Sortierung created_at/desc; wird anders sortiert + oder gefiltert, zeigt die Vorschau konsequenterweise die ersten 4 der + dann gültigen Reihenfolge. */ + const SUPPORTER_VORSCHAU = 4; + let supporterAlleZeigen = false; + + function supporterMehrKnopfSetzen(gesamt) { + const btn = document.getElementById("supporter-mehr-btn"); + if (!btn) return; + const rest = gesamt - SUPPORTER_VORSCHAU; + btn.hidden = rest <= 0; + btn.setAttribute("aria-expanded", supporterAlleZeigen ? "true" : "false"); + btn.innerHTML = supporterAlleZeigen + ? ` Weniger anzeigen` + : ` Alle ${gesamt} anzeigen (${rest} weitere)`; + } + + (function initSupporterMehrKnopf() { + const btn = document.getElementById("supporter-mehr-btn"); + if (!btn) return; + btn.addEventListener("click", () => { + supporterAlleZeigen = !supporterAlleZeigen; + renderTabelle(); + if (!supporterAlleZeigen) { + const sanft = !window.matchMedia("(prefers-reduced-motion: reduce)").matches; + document.getElementById("supp-root").scrollIntoView({ behavior: sanft ? "smooth" : "auto", block: "start" }); + } + }); + })(); + function renderTabelle() { const liste = gefilterteSortierteListe(); const tbody = document.getElementById("supporter-tbody"); @@ -2715,9 +2841,11 @@ ? "Keine Treffer für diese Filter-/Suchkombination." : "Noch keine Einträge — sobald sich jemand als Supporter registriert, taucht er hier auf."; tbody.innerHTML = `${text}