Einbau-Skript news.js für Kundenwebsites
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5a09d060a1
commit
035d1d5517
5 files changed
+547
No files matched your search
@@ -0,0 +1,63 @@
|
||||
# News-Pinnwand auf der Kundenwebsite einbauen
|
||||
|
||||
Anleitung für das WEBklar-Team. Die Pinnwand zeigt die Beiträge, die der Kunde im Portal (Tab „Funktionen“) schreibt. Den `siteKey` findest Du im Portal-Admin beim Projekt (16 Zeichen, a–z und 2–7).
|
||||
|
||||
## 1. Die Stelle auf der Seite
|
||||
|
||||
```html
|
||||
<div data-webklar-news="SITEKEY" data-api="https://project.webklar.com">
|
||||
<!-- statischer Leerzustand, siehe unten -->
|
||||
</div>
|
||||
```
|
||||
|
||||
- `data-webklar-news`: der siteKey des Projekts (Pflicht).
|
||||
- `data-api`: Herkunft des Portals. Ohne Angabe nimmt das Skript die Herkunft aus der Adresse des Skripts.
|
||||
- `data-signature` (optional): Unterschrift unter jedem Zettel, z. B. `data-signature="Opa Martin"`. Ohne Attribut gibt es keine Unterschrift.
|
||||
|
||||
## 2. Das Skript (einmal pro Seite, vor `</body>`)
|
||||
|
||||
```html
|
||||
<script src="https://project.webklar.com/embed/news.js" defer></script>
|
||||
```
|
||||
|
||||
Keine Abhängigkeiten, keine Cookies, kein Browser-Speicher. Kundentexte werden nur als Text eingesetzt, nie als HTML. Das Skript wird 5 Minuten gecacht und darf von jeder Domain geladen werden.
|
||||
|
||||
## 3. Statischer Leerzustand
|
||||
|
||||
Alles, was im `div` steht, gehört der Kundenwebsite. Das Skript ersetzt es nur, wenn die API mindestens einen Beitrag liefert. Bei 0 Beiträgen, Fehler, ausgeschaltetem JavaScript oder unbekanntem Schlüssel bleibt es stehen. Beispiel:
|
||||
|
||||
```html
|
||||
<div data-webklar-news="SITEKEY" data-api="https://project.webklar.com">
|
||||
<h3>Hier hängt bald was Neues.</h3>
|
||||
<p>Neuigkeiten findest Du bald an dieser Pinnwand.</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
Gestalte den Leerzustand im Stil der Kundenseite. Das Skript fasst ihn nicht an.
|
||||
|
||||
## 4. Aussehen anpassen (CSS-Variablen)
|
||||
|
||||
Setze sie auf dem `div` oder in der Seiten-CSS, z. B. `[data-webklar-news]{ --wkn-frame:#2f5d50; }`.
|
||||
|
||||
| Variable | Standard |
|
||||
| --- | --- |
|
||||
| `--wkn-board` (Korkfläche) | `#C08A52` |
|
||||
| `--wkn-frame` (Holzrahmen) | `#A8733F` |
|
||||
| `--wkn-font-display` (Überschriften) | `"Fredoka", system-ui, sans-serif` |
|
||||
| `--wkn-font-body` (Text) | `"Nunito", system-ui, sans-serif` |
|
||||
| `--wkn-paper-sun` | `#FFF6D6` |
|
||||
| `--wkn-paper-sky` | `#DDEEFB` |
|
||||
| `--wkn-paper-coral` | `#FFE1DA` |
|
||||
| `--wkn-paper-white` | `#fff` |
|
||||
|
||||
Die Schriften lädt das Skript nicht selbst. Sind sie auf der Kundenseite nicht eingebunden, greift die Systemschrift. Setze dann die Variablen auf die Schriften der Kundenseite.
|
||||
|
||||
Das Layout richtet sich nach der Breite des `div` (Container Query), nicht nach dem Bildschirm: ab 701 px Breite ein Korkbrett mit drei Spalten, darunter eine Wischreihe mit Punkten. Sechs Beiträge pro Seite, „Ältere Neuigkeiten ansehen“ lädt die nächsten nach. Alle Klassen beginnen mit `wkn-`, die Styles stehen einmalig in `<style id="wkn-style">`.
|
||||
|
||||
## 5. Datenschutzerklärung des Kunden
|
||||
|
||||
Das Skript lädt Beiträge und Bilder von `project.webklar.com`. Dabei sieht der Server die IP-Adresse des Besuchers. Die Datenschutzerklärung des Kunden braucht deshalb den Absatz aus `docs/funktionen/website-datenschutz-absatz.md`. Bau die Pinnwand erst ein, wenn der Absatz dort steht.
|
||||
|
||||
## 6. Testen
|
||||
|
||||
`/embed/news-demo.html` auf dem Portal ist eine nicht verlinkte Testseite. Automatische Prüfung: `node test/ui/embed.ui.mjs`.
|
||||
@@ -0,0 +1,30 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>News-Pinnwand Testseite</title>
|
||||
<style>
|
||||
body{margin:0;font-family:system-ui,sans-serif;background:#fff;color:#232A33}
|
||||
header,footer{padding:16px 24px;background:#f1f3f6}
|
||||
main{max-width:1100px;margin:0 auto;padding:32px 16px}
|
||||
.static-empty{text-align:center;padding:48px 16px;border:2px dashed #C08A52;border-radius:16px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>Testseite der Kundenwebsite (intern, nicht verlinkt)</header>
|
||||
<main>
|
||||
<h2>Neues aus dem Laden</h2>
|
||||
<!-- data-api weglassen = Herkunft des Skripts; data-signature="Opa Martin" optional -->
|
||||
<div data-webklar-news="abcdefghijklmnop">
|
||||
<div class="static-empty" id="static-empty">
|
||||
<h3>Hier hängt bald was Neues.</h3>
|
||||
<p>Neuigkeiten findest du bald an dieser Pinnwand.</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<footer>Fußbereich</footer>
|
||||
<script src="/embed/news.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,244 @@
|
||||
/*! WEBklar Neuigkeiten-Pinnwand: Einbau-Skript fuer Kundenwebsites.
|
||||
* <div data-webklar-news="SITEKEY" data-api="https://project.webklar.com">statischer Leerzustand</div>
|
||||
* Keine Abhaengigkeiten, keine Cookies, kein Storage. Kundentext nur ueber textContent. */
|
||||
(function () {
|
||||
'use strict'
|
||||
var SELF = document.currentScript
|
||||
var STYLE_ID = 'wkn-style'
|
||||
var MONTHS = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez']
|
||||
var ROT = [-1.6, 1.2, -0.8, 1.8, -1.3, 0.9]
|
||||
var PAPERS = { sun: 1, sky: 1, coral: 1, white: 1 }
|
||||
|
||||
var CSS = [
|
||||
'.wkn-root{container-type:inline-size;display:block;--wkn-board:#C08A52;--wkn-frame:#A8733F;--wkn-font-display:"Fredoka",system-ui,sans-serif;--wkn-font-body:"Nunito",system-ui,sans-serif;--wkn-paper-sun:#FFF6D6;--wkn-paper-sky:#DDEEFB;--wkn-paper-coral:#FFE1DA;--wkn-paper-white:#fff;--wkn-ink:#232A33;--wkn-ink-soft:#5B6673;font-family:var(--wkn-font-body)}',
|
||||
'.wkn-root *,.wkn-root *::before,.wkn-root *::after{box-sizing:border-box}',
|
||||
'.wkn-board{position:relative;isolation:isolate;overflow:hidden;border-radius:28px;padding:40px 32px 48px;background-color:var(--wkn-board);',
|
||||
'background-image:url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%27260%27 height=%27260%27%3E%3Cfilter id=%27a%27%3E%3CfeTurbulence type=%27fractalNoise%27 baseFrequency=%271.1%27 numOctaves=%272%27 seed=%273%27/%3E%3CfeColorMatrix values=%270 0 0 0 .29 0 0 0 0 .16 0 0 0 0 .06 0 0 0 -2.4 1.25%27/%3E%3C/filter%3E%3Crect width=%27100%25%27 height=%27100%25%27 filter=%27url(%23a)%27/%3E%3C/svg%3E"),url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%27220%27 height=%27220%27%3E%3Cfilter id=%27b%27%3E%3CfeTurbulence type=%27fractalNoise%27 baseFrequency=%27.85%27 numOctaves=%272%27 seed=%2711%27/%3E%3CfeColorMatrix values=%270 0 0 0 .95 0 0 0 0 .8 0 0 0 0 .58 0 0 0 2.6 -1.55%27/%3E%3C/filter%3E%3Crect width=%27100%25%27 height=%27100%25%27 filter=%27url(%23b)%27/%3E%3C/svg%3E"),url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%27600%27 height=%27600%27%3E%3Cfilter id=%27c%27%3E%3CfeTurbulence type=%27fractalNoise%27 baseFrequency=%27.012%27 numOctaves=%273%27 seed=%277%27/%3E%3CfeColorMatrix values=%270 0 0 0 .45 0 0 0 0 .27 0 0 0 0 .12 0 0 0 1.4 -.45%27/%3E%3C/filter%3E%3Crect width=%27100%25%27 height=%27100%25%27 filter=%27url(%23c)%27/%3E%3C/svg%3E");',
|
||||
'border:12px solid var(--wkn-frame);box-shadow:inset 0 0 0 1px rgba(80,45,15,.35),inset 0 6px 14px rgba(60,32,10,.35),0 0 0 1px rgba(90,55,25,.5),0 12px 30px rgba(35,52,74,.18)}',
|
||||
'.wkn-grid{display:grid;gap:32px 24px;grid-template-columns:repeat(3,minmax(0,1fr));align-items:start}',
|
||||
'.wkn-grid>:first-child{grid-column:span 2}',
|
||||
'.wkn-pin{position:relative;transform:rotate(var(--wkn-r,0deg));transition:transform .25s ease;color:var(--wkn-ink)}',
|
||||
'.wkn-pin:hover{transform:rotate(0deg) translateY(-4px)}',
|
||||
'.wkn-pin::before{content:"";position:absolute;top:-12px;left:50%;width:84px;height:24px;z-index:2;transform:translateX(-50%) rotate(calc(var(--wkn-r,0deg) * -1.5 - 2deg));background:rgba(255,255,255,.62);box-shadow:0 1px 2px rgba(35,52,74,.12);border-left:2px dotted rgba(35,52,74,.06);border-right:2px dotted rgba(35,52,74,.06)}',
|
||||
'.wkn-date{white-space:nowrap;font:700 12px/1 var(--wkn-font-body);letter-spacing:.08em;text-transform:uppercase;color:var(--wkn-ink-soft)}',
|
||||
'.wkn-sig{font:600 15px/1 var(--wkn-font-display);color:var(--wkn-ink-soft);margin-top:14px}',
|
||||
'.wkn-new{position:absolute;top:-14px;right:-10px;z-index:3;transform:rotate(8deg);background:#E4573D;color:#fff;font:700 12px/1 var(--wkn-font-body);letter-spacing:.04em;padding:7px 12px;border-radius:999px;box-shadow:0 3px 8px rgba(35,52,74,.25)}',
|
||||
'.wkn-note{padding:28px 24px 22px;border-radius:6px 6px 18px 6px;box-shadow:0 8px 18px rgba(35,52,74,.14);background:var(--wkn-paper,#fff)}',
|
||||
'.wkn-note h3{font:600 20px/1.25 var(--wkn-font-display);color:var(--wkn-ink);margin:8px 0}',
|
||||
'.wkn-note p{font:400 16px/1.5 var(--wkn-font-body);color:var(--wkn-ink);margin:0;white-space:pre-line;overflow-wrap:anywhere}',
|
||||
'.wkn-lead .wkn-note h3{font-size:28px}',
|
||||
'.wkn-paper-sun{--wkn-paper:var(--wkn-paper-sun)}.wkn-paper-sky{--wkn-paper:var(--wkn-paper-sky)}.wkn-paper-coral{--wkn-paper:var(--wkn-paper-coral)}.wkn-paper-white{--wkn-paper:var(--wkn-paper-white)}',
|
||||
'.wkn-polaroid{margin:0;background:#fff;padding:12px 12px 16px;border-radius:4px;box-shadow:0 10px 22px rgba(35,52,74,.16)}',
|
||||
'.wkn-polaroid img{display:block;width:100%;height:auto;aspect-ratio:4/3;object-fit:cover;border-radius:2px;background:#eef0f3}',
|
||||
'.wkn-polaroid figcaption{display:flex;justify-content:space-between;gap:12px;align-items:baseline;margin-top:12px}',
|
||||
'.wkn-polaroid figcaption b{font:600 17px/1.25 var(--wkn-font-display);color:var(--wkn-ink);overflow-wrap:anywhere}',
|
||||
'.wkn-combo{display:grid;grid-template-columns:1.25fr 1fr;align-items:center}',
|
||||
'.wkn-combo .wkn-polaroid{transform:rotate(-2deg)}',
|
||||
'.wkn-combo .wkn-note{margin-left:-28px;transform:rotate(2.5deg);z-index:1}',
|
||||
'.wkn-combo::before{left:28%}',
|
||||
'.wkn-more[hidden]{display:none}',
|
||||
'.wkn-more{display:flex;justify-content:center;margin-top:40px}',
|
||||
'.wkn-btn{font:700 16px/1 var(--wkn-font-body);color:var(--wkn-ink);background:#fff;border:0;border-radius:999px;padding:14px 24px;cursor:pointer;box-shadow:0 3px 8px rgba(35,52,74,.25)}',
|
||||
'.wkn-btn:hover{transform:translateY(-1px)}.wkn-btn:focus-visible{outline:3px solid var(--wkn-ink);outline-offset:2px}.wkn-btn[disabled]{opacity:.6;cursor:default}',
|
||||
'.wkn-swipehint{display:none;width:max-content;margin:8px auto 0;justify-content:center;align-items:center;gap:6px;font:400 13px/1.2 var(--wkn-font-body);color:var(--wkn-ink);background:rgba(255,255,255,.85);padding:5px 12px;border-radius:999px}',
|
||||
'.wkn-dots{display:flex;gap:6px;margin-right:8px}',
|
||||
'.wkn-dots i{width:7px;height:7px;border-radius:50%;background:rgba(35,42,51,.2);transition:width .2s}',
|
||||
'.wkn-dots i.wkn-on{background:var(--wkn-ink);width:18px;border-radius:999px}',
|
||||
'@container (max-width:700px){',
|
||||
'.wkn-board{padding:32px 0;border-radius:20px}',
|
||||
'.wkn-grid{display:flex;overflow-x:auto;scroll-snap-type:x mandatory;gap:18px;padding:28px 20px 12px;scrollbar-width:none}',
|
||||
'.wkn-grid::-webkit-scrollbar{display:none}',
|
||||
'.wkn-grid>*{flex:0 0 76%;scroll-snap-align:center}',
|
||||
'.wkn-grid>:first-child{grid-column:auto}',
|
||||
'.wkn-combo{grid-template-columns:1fr}',
|
||||
'.wkn-combo .wkn-note{margin:-26px 12px 0 26px}',
|
||||
'.wkn-lead .wkn-note h3{font-size:20px}',
|
||||
'.wkn-more{margin-top:24px;padding-inline:20px}.wkn-more .wkn-btn{width:100%}',
|
||||
'.wkn-swipehint{display:flex}',
|
||||
'}',
|
||||
'@media (prefers-reduced-motion:reduce){.wkn-pin,.wkn-btn,.wkn-dots i{transition:none}}'
|
||||
].join('\n')
|
||||
|
||||
function injectStyle () {
|
||||
if (document.getElementById(STYLE_ID)) return
|
||||
var st = document.createElement('style')
|
||||
st.id = STYLE_ID
|
||||
st.textContent = CSS
|
||||
document.head.appendChild(st)
|
||||
}
|
||||
|
||||
function el (tag, cls, text) {
|
||||
var e = document.createElement(tag)
|
||||
if (cls) e.className = cls
|
||||
if (text != null) e.textContent = text
|
||||
return e
|
||||
}
|
||||
|
||||
// 'YYYY-MM-DD' (bereits Berliner Kalendertag) -> '29. Sep'
|
||||
function fmtDate (d) {
|
||||
var m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(d || ''))
|
||||
if (!m) return ''
|
||||
return parseInt(m[3], 10) + '. ' + MONTHS[parseInt(m[2], 10) - 1]
|
||||
}
|
||||
|
||||
function absUrl (api, p) {
|
||||
return typeof p === 'string' && p.charAt(0) === '/' ? api + p : ''
|
||||
}
|
||||
|
||||
function buildNote (p, extraCls) {
|
||||
var paper = PAPERS[p.paper] ? p.paper : 'sun'
|
||||
var n = el('div', 'wkn-note wkn-paper-' + paper + (extraCls || ''))
|
||||
n.appendChild(el('span', 'wkn-date', fmtDate(p.date)))
|
||||
if (p.title) n.appendChild(el('h3', '', p.title))
|
||||
n.appendChild(el('p', '', p.text || ''))
|
||||
if (p.signature) n.appendChild(el('div', 'wkn-sig', p.signature))
|
||||
return n
|
||||
}
|
||||
|
||||
function buildPhoto (p, api, withCaption) {
|
||||
var fig = el('figure', 'wkn-polaroid')
|
||||
if (p.image) {
|
||||
var img = document.createElement('img')
|
||||
var small = absUrl(api, p.image.srcSmall)
|
||||
var full = absUrl(api, p.image.src)
|
||||
img.src = small || full
|
||||
if (small && full) {
|
||||
img.srcset = small + ' 480w, ' + full + ' ' + (p.image.w || 1600) + 'w'
|
||||
img.sizes = '(max-width:700px) 80vw, 480px'
|
||||
}
|
||||
if (p.image.w && p.image.h) { img.width = p.image.w; img.height = p.image.h }
|
||||
img.alt = p.title || ''
|
||||
img.loading = 'lazy'
|
||||
img.decoding = 'async'
|
||||
fig.appendChild(img)
|
||||
}
|
||||
if (withCaption) {
|
||||
var cap = el('figcaption')
|
||||
if (p.title) cap.appendChild(el('b', '', p.title))
|
||||
cap.appendChild(el('span', 'wkn-date', fmtDate(p.date)))
|
||||
fig.appendChild(cap)
|
||||
}
|
||||
return fig
|
||||
}
|
||||
|
||||
function buildPin (p, index, isFirst, api, signature) {
|
||||
var cls = 'wkn-pin' + (isFirst ? ' wkn-lead' : '')
|
||||
var r = ROT[index % ROT.length]
|
||||
var item
|
||||
p = { id: p.id, type: p.type, title: p.title, text: p.text, paper: p.paper, date: p.date, image: p.image, signature: signature }
|
||||
if (p.type === 'photo' && p.image) {
|
||||
item = buildPhoto(p, api, true)
|
||||
item.className += ' ' + cls
|
||||
item.style.setProperty('--wkn-r', r + 'deg')
|
||||
} else if (p.type === 'combo' && p.image) {
|
||||
item = el('article', cls + ' wkn-combo')
|
||||
item.style.setProperty('--wkn-r', '0deg')
|
||||
item.appendChild(buildPhoto(p, api, false))
|
||||
item.appendChild(buildNote(p))
|
||||
} else {
|
||||
item = el('article', cls)
|
||||
item.style.setProperty('--wkn-r', r + 'deg')
|
||||
var note = buildNote(p)
|
||||
// Zettel-Klassen direkt auf die Pin-Huelle, damit Polster/Schatten nur einmal greifen
|
||||
item.className += ' ' + note.className
|
||||
while (note.firstChild) item.appendChild(note.firstChild)
|
||||
}
|
||||
if (isFirst) item.appendChild(el('span', 'wkn-new', 'Neu'))
|
||||
return item
|
||||
}
|
||||
|
||||
function init (root) {
|
||||
var key = root.getAttribute('data-webklar-news')
|
||||
if (!key || root.getAttribute('data-wkn-init')) return
|
||||
root.setAttribute('data-wkn-init', '1')
|
||||
var apiAttr = root.getAttribute('data-api')
|
||||
var api = String(apiAttr || '').replace(/\/+$/, '')
|
||||
if (!api && SELF && SELF.src) api = new URL(SELF.src).origin
|
||||
var signature = root.getAttribute('data-signature') || ''
|
||||
var state = { page: 1, shown: 0, ui: null, loading: false }
|
||||
|
||||
function fetchPage (page) {
|
||||
return fetch(api + '/api/public/news/' + encodeURIComponent(key) + '?page=' + page, { headers: { Accept: 'application/json' } })
|
||||
.then(function (r) { if (!r.ok) throw new Error('http ' + r.status); return r.json() })
|
||||
.then(function (d) {
|
||||
if (!d || !Array.isArray(d.posts)) throw new Error('format')
|
||||
return d
|
||||
})
|
||||
}
|
||||
|
||||
function updateDots () {
|
||||
var ui = state.ui
|
||||
if (!ui) return
|
||||
var kids = ui.grid.children
|
||||
var center = ui.grid.scrollLeft + ui.grid.clientWidth / 2
|
||||
var best = 0, bestDist = Infinity
|
||||
for (var i = 0; i < kids.length; i++) {
|
||||
var c = kids[i].offsetLeft + kids[i].offsetWidth / 2
|
||||
if (Math.abs(c - center) < bestDist) { bestDist = Math.abs(c - center); best = i }
|
||||
}
|
||||
for (var j = 0; j < ui.dots.children.length; j++) ui.dots.children[j].className = j === best ? 'wkn-on' : ''
|
||||
}
|
||||
|
||||
function append (posts) {
|
||||
var ui = state.ui
|
||||
posts.forEach(function (p) {
|
||||
var idx = state.shown
|
||||
ui.grid.appendChild(buildPin(p, idx, idx === 0, api, signature))
|
||||
ui.dots.appendChild(document.createElement('i'))
|
||||
state.shown++
|
||||
})
|
||||
updateDots()
|
||||
}
|
||||
|
||||
function setMore (hasMore) {
|
||||
var ui = state.ui
|
||||
ui.more.hidden = !hasMore
|
||||
ui.btn.disabled = false
|
||||
}
|
||||
|
||||
function build (d) {
|
||||
root.classList.add('wkn-root')
|
||||
root.textContent = ''
|
||||
var board = el('div', 'wkn-board')
|
||||
var grid = el('div', 'wkn-grid')
|
||||
var hint = el('div', 'wkn-swipehint')
|
||||
var dots = el('span', 'wkn-dots')
|
||||
hint.appendChild(dots)
|
||||
hint.appendChild(document.createTextNode('wischen für mehr'))
|
||||
var more = el('div', 'wkn-more')
|
||||
var btn = el('button', 'wkn-btn', 'Ältere Neuigkeiten ansehen')
|
||||
btn.type = 'button'
|
||||
more.appendChild(btn)
|
||||
board.appendChild(grid)
|
||||
board.appendChild(hint)
|
||||
board.appendChild(more)
|
||||
root.appendChild(board)
|
||||
state.ui = { grid: grid, dots: dots, more: more, btn: btn }
|
||||
grid.addEventListener('scroll', updateDots, { passive: true })
|
||||
btn.addEventListener('click', function () {
|
||||
if (state.loading) return
|
||||
state.loading = true
|
||||
btn.disabled = true
|
||||
fetchPage(state.page + 1).then(function (nd) {
|
||||
state.page++
|
||||
append(nd.posts)
|
||||
setMore(!!nd.hasMore && nd.posts.length > 0)
|
||||
}).catch(function () { btn.disabled = false }).then(function () { state.loading = false })
|
||||
})
|
||||
append(d.posts)
|
||||
setMore(!!d.hasMore)
|
||||
}
|
||||
|
||||
injectStyle()
|
||||
fetchPage(1).then(function (d) {
|
||||
if (d.posts.length > 0) build(d)
|
||||
}).catch(function () { /* Fehler: statischer Leerzustand der Seite bleibt */ })
|
||||
}
|
||||
|
||||
function run () {
|
||||
var nodes = document.querySelectorAll('[data-webklar-news]')
|
||||
for (var i = 0; i < nodes.length; i++) init(nodes[i])
|
||||
}
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run)
|
||||
else run()
|
||||
})()
|
||||
@@ -91,6 +91,12 @@ app.get('/api/health', async (_req, res) => {
|
||||
})
|
||||
})
|
||||
|
||||
// Einbau-Skript fuer Kundenwebsites: darf von fremden Seiten geladen werden, 5 Minuten Cache
|
||||
app.use('/embed/', (_req, res, next) => {
|
||||
res.set('Cache-Control', 'public, max-age=300')
|
||||
res.set('Access-Control-Allow-Origin', '*')
|
||||
next()
|
||||
})
|
||||
app.use(express.static(publicDir))
|
||||
|
||||
app.get('/dashboard.html', (req, res, next) => {
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
// Playwright-Checks Einbau-Skript public/embed/news.js: statischer Server fuer public/ + per page.route gemockte API.
|
||||
// Nicht Teil von npm test. Aufruf: node test/ui/embed.ui.mjs
|
||||
// Screenshots landen in /root/reports/funktionen-news-ui/embed-*.png.
|
||||
import http from 'node:http'
|
||||
import { readFile, mkdir } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const PW = process.env.PLAYWRIGHT_CORE || '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs'
|
||||
const { chromium } = await import(PW)
|
||||
|
||||
const pub = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public')
|
||||
const SHOTS = '/root/reports/funktionen-news-ui'
|
||||
await mkdir(SHOTS, { recursive: true })
|
||||
const TYPES = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css' }
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, 'http://x')
|
||||
try {
|
||||
const file = path.join(pub, url.pathname)
|
||||
const data = await readFile(file)
|
||||
res.writeHead(200, { 'content-type': TYPES[path.extname(file)] || 'application/octet-stream' })
|
||||
res.end(data)
|
||||
} catch {
|
||||
res.writeHead(404)
|
||||
res.end('nicht gefunden')
|
||||
}
|
||||
})
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
const base = `http://127.0.0.1:${server.address().port}`
|
||||
const DEMO = `${base}/embed/news-demo.html`
|
||||
|
||||
// ---- Fixtures ----
|
||||
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64')
|
||||
const PAPERS = ['sun', 'sky', 'coral', 'white']
|
||||
const KINDS = ['combo', 'note', 'photo', 'note', 'combo', 'note', 'photo', 'note']
|
||||
function makePosts(n, offset = 0) {
|
||||
return Array.from({ length: n }, (_, k) => {
|
||||
const i = k + offset
|
||||
const type = KINDS[i % KINDS.length]
|
||||
return {
|
||||
id: `p${i}`, type, title: i === 3 ? '' : `Beitrag ${i + 1}`, text: type === 'photo' ? '' : `Text zu Beitrag ${i + 1}. Frisch reingekommen, komm vorbei.`,
|
||||
paper: PAPERS[i % 4], date: '2026-09-29',
|
||||
image: type === 'note' ? null : { w: 1200, h: 900, src: `/api/public/news/abcdefghijklmnop/img/p${i}.webp?v=1`, srcSmall: `/api/public/news/abcdefghijklmnop/img/p${i}.webp?v=1&s=1` },
|
||||
}
|
||||
})
|
||||
}
|
||||
async function mockApi(page, { total = 0, status = 200, custom = null } = {}) {
|
||||
await page.route('**/api/public/news/**', async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.pathname.includes('/img/')) return route.fulfill({ status: 200, contentType: 'image/png', body: PNG })
|
||||
if (status !== 200) return route.fulfill({ status, contentType: 'application/json', body: '{"error":"down"}' })
|
||||
if (custom) return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ posts: custom, hasMore: false }) })
|
||||
const pg = Number(url.searchParams.get('page') || 1)
|
||||
const all = makePosts(total)
|
||||
const posts = all.slice((pg - 1) * 6, pg * 6)
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ posts, hasMore: pg * 6 < total }) })
|
||||
})
|
||||
}
|
||||
|
||||
const browser = await chromium.launch()
|
||||
const results = []
|
||||
async function check(name, fn, { viewport = { width: 1280, height: 900 }, javaScriptEnabled = true } = {}) {
|
||||
const ctx = await browser.newContext({ viewport, javaScriptEnabled })
|
||||
const page = await ctx.newPage()
|
||||
const errors = []
|
||||
page.on('pageerror', (e) => errors.push(e.message))
|
||||
try {
|
||||
await fn(page, errors)
|
||||
results.push([name, true])
|
||||
console.log(`ok ${name}`)
|
||||
} catch (e) {
|
||||
results.push([name, false])
|
||||
console.log(`FAIL ${name}: ${e.message}`)
|
||||
}
|
||||
await ctx.close()
|
||||
}
|
||||
function assert(cond, msg) { if (!cond) throw new Error(msg) }
|
||||
|
||||
await check('leer_bleibt_leerzustand', async (page, errors) => {
|
||||
await mockApi(page, { total: 0 })
|
||||
await page.goto(DEMO)
|
||||
await page.waitForLoadState('networkidle')
|
||||
assert(await page.locator('#static-empty').isVisible(), 'Leerzustand nicht sichtbar')
|
||||
assert((await page.locator('.wkn-board').count()) === 0, 'Brett trotz 0 Posts')
|
||||
assert((await page.locator('#wkn-style').count()) <= 1, 'Style mehrfach')
|
||||
assert(errors.length === 0, errors.join())
|
||||
})
|
||||
|
||||
await check('ein_beitrag_neu_schild', async (page) => {
|
||||
await mockApi(page, { total: 1 })
|
||||
await page.goto(DEMO)
|
||||
await page.locator('.wkn-pin').first().waitFor()
|
||||
assert((await page.locator('.wkn-pin').count()) === 1, 'nicht genau 1 Zettel')
|
||||
assert((await page.locator('.wkn-new').count()) === 1, 'Neu-Schild fehlt')
|
||||
assert((await page.locator('.wkn-new').innerText()) === 'Neu', 'Schild-Text')
|
||||
assert((await page.locator('#static-empty').count()) === 0, 'Leerzustand blieb stehen')
|
||||
assert((await page.locator('.wkn-more').isHidden()), 'Knopf trotz hasMore=false sichtbar')
|
||||
assert((await page.locator('#wkn-style').count()) === 1, 'Style nicht genau einmal')
|
||||
assert((await page.locator('.wkn-date').first().innerText()).toLowerCase() === '29. sep', 'Datum: ' + await page.locator('.wkn-date').first().innerText())
|
||||
})
|
||||
|
||||
await check('acht_beitraege_aeltere', async (page) => {
|
||||
await mockApi(page, { total: 8 })
|
||||
await page.goto(DEMO)
|
||||
await page.locator('.wkn-pin').first().waitFor()
|
||||
assert((await page.locator('.wkn-pin').count()) === 6, '6 erwartet')
|
||||
const btn = page.getByRole('button', { name: 'Ältere Neuigkeiten ansehen' })
|
||||
assert(await btn.isVisible(), 'Knopf fehlt')
|
||||
await btn.click()
|
||||
await page.waitForFunction(() => document.querySelectorAll('.wkn-pin').length === 8)
|
||||
assert(await btn.isHidden(), 'Knopf nicht verschwunden')
|
||||
assert((await page.locator('.wkn-new').count()) === 1, 'Neu nur am neuesten')
|
||||
})
|
||||
|
||||
await check('xss_als_text', async (page, errors) => {
|
||||
const evil = '<img src=x onerror=window.__x=1>'
|
||||
await mockApi(page, { custom: [{ id: 'x', type: 'note', title: evil, text: evil, paper: 'sun', date: '2026-09-29', image: null }] })
|
||||
await page.goto(DEMO)
|
||||
await page.locator('.wkn-pin').first().waitFor()
|
||||
await page.waitForTimeout(300)
|
||||
assert((await page.locator('.wkn-pin h3').innerText()) === evil, 'Titel nicht als Text')
|
||||
assert((await page.locator('.wkn-pin img').count()) === 0, 'img-Element eingeschleust')
|
||||
assert((await page.evaluate(() => window.__x)) === undefined, 'window.__x gesetzt')
|
||||
})
|
||||
|
||||
await check('api_down_kein_fehler', async (page, errors) => {
|
||||
await mockApi(page, { status: 503 })
|
||||
await page.goto(DEMO)
|
||||
await page.waitForLoadState('networkidle')
|
||||
await page.waitForTimeout(200)
|
||||
assert(await page.locator('#static-empty').isVisible(), 'Leerzustand nicht sichtbar')
|
||||
assert(errors.length === 0, 'Exception: ' + errors.join())
|
||||
})
|
||||
|
||||
await check('ohne_js', async (page) => {
|
||||
await mockApi(page, { total: 3 })
|
||||
await page.goto(DEMO)
|
||||
assert(await page.locator('#static-empty').isVisible(), 'Leerzustand nicht sichtbar')
|
||||
}, { javaScriptEnabled: false })
|
||||
|
||||
await check('handy_wischreihe', async (page) => {
|
||||
await mockApi(page, { total: 6 })
|
||||
await page.goto(DEMO)
|
||||
await page.locator('.wkn-pin').first().waitFor()
|
||||
const info = await page.evaluate(() => {
|
||||
const g = document.querySelector('.wkn-grid')
|
||||
const cs = getComputedStyle(g)
|
||||
return { snap: cs.scrollSnapType, ox: cs.overflowX, flex: cs.display, gw: g.scrollWidth, cw: g.clientWidth, doc: document.documentElement.scrollWidth, win: window.innerWidth, hint: getComputedStyle(document.querySelector('.wkn-swipehint')).display, dots: document.querySelectorAll('.wkn-dots i').length }
|
||||
})
|
||||
assert(/x\s+mandatory/.test(info.snap), 'kein Scroll-Snap: ' + info.snap)
|
||||
assert(info.ox === 'auto' && info.flex === 'flex', 'keine Reihe')
|
||||
assert(info.gw > info.cw, 'Reihe scrollt nicht')
|
||||
assert(info.doc <= info.win, `Seite scrollt horizontal (${info.doc} > ${info.win})`)
|
||||
assert(info.hint === 'flex' && info.dots === 6, 'Wisch-Hinweis/Punkte')
|
||||
}, { viewport: { width: 390, height: 844 } })
|
||||
|
||||
await check('keine_cookies_storage', async (page) => {
|
||||
await mockApi(page, { total: 8 })
|
||||
await page.goto(DEMO)
|
||||
await page.locator('.wkn-pin').first().waitFor()
|
||||
const s = await page.evaluate(() => ({ c: document.cookie, l: localStorage.length, s: sessionStorage.length }))
|
||||
assert(s.c === '' && s.l === 0 && s.s === 0, JSON.stringify(s))
|
||||
})
|
||||
|
||||
await check('signatur_optional', async (page) => {
|
||||
await mockApi(page, { total: 2 })
|
||||
await page.route('**/embed/news-demo.html', async (route) => {
|
||||
const r = await route.fetch()
|
||||
await route.fulfill({ response: r, body: (await r.text()).replace('data-webklar-news="abcdefghijklmnop"', 'data-webklar-news="abcdefghijklmnop" data-signature="Opa Martin"') })
|
||||
})
|
||||
await page.goto(DEMO)
|
||||
await page.locator('.wkn-pin').first().waitFor()
|
||||
assert((await page.locator('.wkn-sig').count()) > 0, 'Signatur fehlt')
|
||||
assert((await page.locator('.wkn-sig').first().innerText()) === 'Opa Martin', 'Signaturtext')
|
||||
})
|
||||
|
||||
await check('ohne_titel_keine_ueberschrift', async (page) => {
|
||||
await mockApi(page, { custom: [{ id: 'n', type: 'note', title: '', text: 'Nur Text', paper: 'sky', date: '2026-09-29', image: null }] })
|
||||
await page.goto(DEMO)
|
||||
await page.locator('.wkn-pin').first().waitFor()
|
||||
assert((await page.locator('.wkn-pin h3').count()) === 0, 'leere Ueberschrift')
|
||||
assert((await page.locator('.wkn-sig').count()) === 0, 'Signatur ohne Attribut')
|
||||
})
|
||||
|
||||
// Screenshots
|
||||
for (const [label, viewport] of [['desktop', { width: 1280, height: 900 }], ['phone', { width: 390, height: 844 }]]) {
|
||||
for (const n of [1, 8]) {
|
||||
const ctx = await browser.newContext({ viewport })
|
||||
const page = await ctx.newPage()
|
||||
await mockApi(page, { total: n })
|
||||
await page.goto(DEMO)
|
||||
await page.locator('.wkn-pin').first().waitFor()
|
||||
await page.waitForLoadState('networkidle')
|
||||
await page.screenshot({ path: `${SHOTS}/embed-${label}-${n}.png`, fullPage: true })
|
||||
await ctx.close()
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close()
|
||||
server.close()
|
||||
const failed = results.filter(([, ok]) => !ok)
|
||||
console.log(`${results.length - failed.length}/${results.length} Checks bestanden`)
|
||||
process.exit(failed.length ? 1 : 0)
|
||||
Reference in new issue
Block a user