Files
Webklar-Kundenbereich/public/funktionen.js
T

508 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Tab "Funktionen" und News-Pinnwand im Kundenbereich (ES-Modul, wird von app.js erst beim Oeffnen geladen).
initFunktionen(rootEl): Kacheln der Funktionen. initNews(rootEl, { projectId }): je nach Zustand der Funktion
Buchungsseite (off), Einrichtungsstatus (ordered/setup) oder Editor (active). Styles: funktionen.css.
Kundentexte kommen nur ueber esc() bzw. textContent in die Seite. */
const PRICE = '30 €'
const LEISTUNGSBESCHREIBUNG = '/vertragsunterlagen/funktionen-leistungsbeschreibung.html'
const PHOTO_ACCEPT = 'image/jpeg,image/png,image/webp' // iOS wandelt HEIC dann selbst in JPEG um
const PHOTO_MAX_BYTES = 10 * 1024 * 1024
const TITLE_MAX = 60
const TEXT_MAX = 400
const DURATIONS = [
['1w', '1 Woche'], ['2w', '2 Wochen'], ['1m', '1 Monat'], ['3m', '3 Monate'],
['open', 'Bis ich sie abnehme'], ['date', 'Bis zu einem Datum'],
]
const REHANG_DURATIONS = DURATIONS.filter(([key]) => key !== 'date')
const PAPERS = [['sun', 'Sonnengelb'], ['sky', 'Himmelblau'], ['coral', 'Koralle'], ['white', 'Weiß']]
const TYPES = [
['note', 'Text', 'ein Zettel', '<path d="M12 20h9"/><path d="M16.4 3.6a1 1 0 0 1 3 3L7.4 18.6a2 2 0 0 1-.9.5l-2.9.8a.5.5 0 0 1-.6-.6l.8-2.9a2 2 0 0 1 .5-.9z"/>'],
['photo', 'Foto', 'ein Polaroid', '<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3z"/><circle cx="12" cy="13" r="3"/>'],
['combo', 'Foto + Text', 'beides', '<rect x="3" y="3" width="12" height="12" rx="2"/><path d="M9 21h10a2 2 0 0 0 2-2V9"/><path d="m3 11 3-3 4 4"/>'],
]
const esc = (value) => String(value ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
const safeUrl = (url) => (/^https?:\/\//i.test(url || '') ? url : '')
function fmtDay(value) {
const d = new Date(value)
if (!value || Number.isNaN(d.getTime())) return ''
return d.toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' })
}
// "2026-10-01" in der Zeitzone Berlin, plus Tage (fuer min/max des Datumsfelds)
function berlinIsoDay(offsetDays = 0) {
const d = new Date(Date.now() + offsetDays * 86400000)
return d.toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
}
async function request(path, { method = 'GET', body, raw } = {}) {
const response = await fetch(path, {
method,
credentials: 'same-origin',
headers: raw ? { 'Content-Type': 'application/octet-stream' } : body ? { 'Content-Type': 'application/json' } : {},
body: raw || (body ? JSON.stringify(body) : undefined),
})
const data = await response.json().catch(() => ({}))
if (!response.ok) {
const err = new Error(data.error || (response.status === 502 ? 'Server nicht erreichbar. Bitte kurz warten und noch einmal versuchen.' : `Fehler ${response.status}`))
err.status = response.status
if (data.field) err.field = data.field
throw err
}
return data
}
/* ------------------------------------------------------------ Zustand --- */
// Das im News-Bereich gewaehlte Projekt bleibt beim Wechseln zwischen Kacheln und News erhalten
const selection = { projectId: '' }
const newsOf = (project) => project.features?.news || { state: 'off' }
const isSetup = (state) => state === 'ordered' || state === 'setup'
// Vorauswahl: zuerst ein aktives Projekt, dann eins in Einrichtung, sonst das erste
function pickProject(projects) {
return projects.find((p) => newsOf(p).state === 'active') || projects.find((p) => isSetup(newsOf(p).state)) || projects.find((p) => newsOf(p).state === 'off') || projects[0]
}
/* ------------------------------------------------------------ Kacheln --- */
const ICONS = {
news: '<path d="M12 17v5"/><path d="M9 10.8a2 2 0 0 1-1.1 1.8l-1.8.9A2 2 0 0 0 5 15.2V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.8a2 2 0 0 0-1.1-1.8l-1.8-.9A2 2 0 0 1 15 10.8V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/>',
calendar: '<rect x="3" y="5" width="18" height="16" rx="3"/><path d="M8 3v4M16 3v4M3 10h18"/>',
chart: '<path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/>',
}
const svg = (name) => `<svg class="fn-ico" viewBox="0 0 24 24" aria-hidden="true">${ICONS[name]}</svg>`
function newsTile(projects) {
const states = projects.map((p) => newsOf(p).state)
const active = states.filter((s) => s === 'active').length
let pill = ['Aus', 'off']
let cta = `Einrichten lassen · ${PRICE}`
if (active) { pill = ['Aktiv', 'on']; cta = 'Beiträge schreiben' } else if (states.some(isSetup)) { pill = ['In Einrichtung', 'setup']; cta = 'Status ansehen' } else if (states.length && states.every((s) => s === 'disabled')) { pill = ['Abgeschaltet', 'off']; cta = 'Status ansehen' }
const target = pickProject(projects)
const count = projects.length > 1 && active ? `<p class="fn-count">aktiv für ${active} von ${projects.length} Websites</p>` : ''
return `
<article class="fn-tile" data-fn-tile="news">
<div class="fn-tile-head">${svg('news')}<span class="fn-pill fn-pill-${pill[1]}">${pill[0]}</span></div>
<h3>News-Pinnwand</h3>
<p>Schreib kurze Neuigkeiten mit Text und Foto – sie hängen wie Zettel an einer Pinnwand auf deiner Website.</p>
${count}
${target
? `<button type="button" class="kb-btn-primary fn-tile-cta" data-view="news" data-project="${esc(target.projectId)}">${cta}</button>`
: '<p class="fn-count">Sobald deine Website angelegt ist, kannst du die Pinnwand buchen.</p>'}
</article>`
}
const soonTile = (icon, title, text) => `
<article class="fn-tile fn-tile-soon">
<div class="fn-tile-head">${svg(icon)}<span class="fn-pill fn-pill-soon">Kommt bald</span></div>
<h3>${title}</h3>
<p>${text}</p>
</article>`
export async function initFunktionen(root) {
if (!root) return
const { projects = [] } = await request('/api/funktionen')
root.innerHTML = `
<div class="fn-tiles">
${newsTile(projects)}
${soonTile('calendar', 'Terminplaner', 'Deine Kundschaft bucht Termine direkt über deine Website.')}
${soonTile('chart', 'Analyse', 'Sieh, wie viele Besucher deine Website hat und was sie sich ansehen.')}
</div>`
root.querySelector('.fn-tile-cta')?.addEventListener('click', (ev) => { selection.projectId = ev.currentTarget.dataset.project })
}
/* ---------------------------------------------------------------- News --- */
export async function initNews(root, { projectId } = {}) {
if (!root) return
if (projectId) selection.projectId = projectId
const { projects = [], setupEstimate = '', consentText = '' } = await request('/api/funktionen')
if (!projects.length) {
root.innerHTML = '<div class="kb-panel kb-empty">Noch keine Website-Projekte zugewiesen.</div>'
return
}
const project = projects.find((p) => p.projectId === selection.projectId) || pickProject(projects)
selection.projectId = project.projectId
const feature = newsOf(project)
// Rueckkehr von Stripe (#news?bestellt=1): Flag nur einmal auswerten und aus der Adresse nehmen
const orderedFlag = /[?&]bestellt=1\b/.test(window.location.hash)
if (orderedFlag) window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}#news`)
const ordered = orderedFlag && isSetup(feature.state)
// Editor nicht neu aufbauen, solange Projekt und Zustand gleich sind: Entwurf und hochgeladenes Foto bleiben
const renderKey = `${project.projectId}:${feature.state}`
if (feature.state === 'active' && root.dataset.newsKey === renderKey && root.querySelector('.nw')) return
root.dataset.newsKey = feature.state === 'active' ? renderKey : ''
root.innerHTML = `
${projects.length > 1 ? `
<div class="fn-project kb-panel">
<label for="news-project">Website</label>
<select id="news-project">${projects.map((p) => `<option value="${esc(p.projectId)}"${p.projectId === project.projectId ? ' selected' : ''}>${esc(p.projectName)}</option>`).join('')}</select>
</div>` : ''}
<div id="news-stage"></div>`
root.querySelector('#news-project')?.addEventListener('change', (ev) => {
selection.projectId = ev.target.value
initNews(root).catch((err) => { root.innerHTML = `<p class="muted">${esc(err.message)}</p>` })
})
const stage = root.querySelector('#news-stage')
const thanks = ordered
? '<p class="fn-thanks" role="status">Danke! Sobald die Zahlung da ist, legen wir los.</p>'
: ''
if (feature.state === 'active') renderEditor(stage, project)
else if (isSetup(feature.state)) stage.innerHTML = thanks + statusCard(feature, setupEstimate)
else if (feature.state === 'disabled') stage.innerHTML = '<div class="kb-panel"><h3>News-Pinnwand ist abgeschaltet</h3><p>Für diese Website ist die Pinnwand im Moment nicht aktiv. <button type="button" class="kb-link" data-view="chat">Schreib uns im Chat</button>, dann klären wir das.</p></div>'
else renderBooking(stage, project, { setupEstimate, consentText, thanks })
}
function renderBooking(stage, project, { setupEstimate, consentText, thanks }) {
stage.innerHTML = `
${thanks}
<div class="kb-panel fn-booking">
<h3>News-Pinnwand einrichten lassen</h3>
<p>Wir bauen die Pinnwand in deine Website <strong>${esc(project.projectName)}</strong> ein. Danach schreibst du deine Neuigkeiten selbst hier im Kundenbereich – mit Text, Foto und Anzeigedauer.</p>
<ul class="kb-checks">
<li>Einrichtung und Einbau durch uns</li>
<li>Beiträge jederzeit selbst schreiben, ändern und abnehmen</li>
<li>Nutzung danach kostenlos, kein Abo</li>
</ul>
<p class="fn-price"><strong>${PRICE}</strong> <span>einmalig, inklusive Mehrwertsteuer</span></p>
${setupEstimate ? `<p class="fn-estimate">Einrichtung: ${esc(setupEstimate)}</p>` : ''}
<p class="fn-desc-link"><a href="${LEISTUNGSBESCHREIBUNG}" target="_blank" rel="noopener">Leistungsbeschreibung</a></p>
<label class="fn-consent"><input type="checkbox" id="news-consent"><span id="news-consent-text"></span></label>
<p class="error hidden" id="news-order-error" role="alert"></p>
<div class="abo-actions"><button type="button" class="abo-btn" id="news-order" disabled>Zahlungspflichtig bestellen · ${PRICE}</button></div>
</div>`
stage.querySelector('#news-consent-text').textContent = consentText
const box = stage.querySelector('#news-consent')
const button = stage.querySelector('#news-order')
const error = stage.querySelector('#news-order-error')
box.addEventListener('change', () => { button.disabled = !box.checked })
button.addEventListener('click', async () => {
if (!box.checked) return
button.disabled = true
error.classList.add('hidden')
try {
const { url } = await request('/api/funktionen/news/checkout', { method: 'POST', body: { projectId: project.projectId, consent: true } })
window.location.href = url
} catch (err) {
error.textContent = err.message
error.classList.remove('hidden')
button.disabled = !box.checked
}
})
}
function statusCard(feature, setupEstimate) {
const rows = [
feature.ticketWoid ? `<div><dt>Ticketnummer</dt><dd>#${esc(feature.ticketWoid)}</dd></div>` : '',
feature.orderedAt ? `<div><dt>Bestellt am</dt><dd>${esc(fmtDay(feature.orderedAt))}</dd></div>` : '',
setupEstimate ? `<div><dt>Zeiteinschätzung</dt><dd>${esc(setupEstimate)}</dd></div>` : '',
].join('')
const invoice = safeUrl(feature.invoiceUrl)
return `
<div class="kb-panel fn-status">
<span class="fn-pill fn-pill-setup">In Einrichtung</span>
<h3>Wir richten deine News-Pinnwand ein</h3>
<p>${feature.state === 'ordered' ? 'Sobald deine Zahlung bei uns ankommt, legen wir los.' : 'Sobald sie fertig ist, kannst du hier deine ersten Beiträge schreiben.'}</p>
${rows ? `<dl class="fn-facts">${rows}</dl>` : ''}
<div class="abo-actions">
${invoice ? `<a class="kb-btn-outline" href="${esc(invoice)}" target="_blank" rel="noopener">Rechnung ansehen</a>` : ''}
<button type="button" class="kb-btn-outline" data-view="chat">Frage dazu? Chat öffnen</button>
</div>
</div>`
}
/* -------------------------------------------------------------- Editor --- */
function renderEditor(stage, project) {
const pid = project.projectId
const q = `projectId=${encodeURIComponent(pid)}`
const imageUrl = (imageId, size = 's') => `/api/news/image/${encodeURIComponent(pid)}/${encodeURIComponent(imageId)}-${size}.webp`
const fresh = () => ({ editId: '', type: 'combo', title: '', text: '', paper: 'sun', duration: '1m', date: '', imageId: '', imageSrc: '', uploading: false })
let ed = fresh()
let liveUrl = ''
let list = { hanging: [], expired: [] }
stage.innerHTML = `
<div class="nw">
<div class="nw-form">
<div class="kb-panel nw-card">
<p class="nw-step" id="nw-step-title">Neuer Zettel</p>
<h3>Was möchtest du aufhängen?</h3>
<div class="nw-types">
${TYPES.map(([key, label, sub, icon]) => `
<button type="button" class="nw-type" data-type="${key}" aria-pressed="false">
<span class="nw-type-ico"><svg viewBox="0 0 24 24" aria-hidden="true">${icon}</svg></span>
${label}<small>${sub}</small>
</button>`).join('')}
</div>
</div>
<div class="kb-panel nw-card">
<div class="nw-field" id="nw-photo-field">
<label>Foto</label>
<div class="nw-drop" id="nw-drop">
<div class="nw-drop-img hidden" id="nw-drop-img"><img alt=""></div>
<div class="nw-drop-btns">
<label class="kb-btn-outline nw-only-phone"><input type="file" accept="${PHOTO_ACCEPT}" capture="environment" hidden>Foto aufnehmen</label>
<label class="kb-btn-outline" id="nw-pick"><input type="file" accept="${PHOTO_ACCEPT}" hidden><span>Foto auswählen</span></label>
</div>
<p class="nw-hint" id="nw-photo-hint">JPEG, PNG, WebP oder iPhone-Foto, bis 10 MB.</p>
</div>
<p class="nw-err" data-err="photo" role="alert"></p>
</div>
<div class="nw-field">
<label for="nw-title" id="nw-title-label">Überschrift (freiwillig)</label>
<input type="text" id="nw-title" maxlength="${TITLE_MAX}" autocomplete="off">
<p class="nw-err" data-err="title" role="alert"></p>
</div>
<div class="nw-field" id="nw-text-field">
<label for="nw-text">Text</label>
<textarea id="nw-text" rows="4" maxlength="${TEXT_MAX}"></textarea>
<p class="nw-hint nw-hint-row"><span>Kurz und herzlich wirkt am besten.</span><span id="nw-count">0 / ${TEXT_MAX}</span></p>
<p class="nw-err" data-err="text" role="alert"></p>
</div>
<div class="nw-field" id="nw-paper-field">
<label>Zettelfarbe</label>
<div class="nw-swatches">
${PAPERS.map(([key, label]) => `<button type="button" class="nw-sw nw-paper-${key}" data-paper="${key}" aria-pressed="false" aria-label="${label}" title="${label}"></button>`).join('')}
</div>
<p class="nw-err" data-err="paper" role="alert"></p>
</div>
<div class="nw-field">
<label for="nw-duration">Wie lange soll der Zettel hängen?</label>
<select id="nw-duration">${DURATIONS.map(([key, label]) => `<option value="${key}"${key === '1m' ? ' selected' : ''}>${label}</option>`).join('')}</select>
<input type="date" id="nw-date" class="hidden" min="${berlinIsoDay(1)}" max="${berlinIsoDay(365)}" aria-label="Letzter Tag">
<p class="nw-err" data-err="duration" role="alert"></p>
</div>
<p class="nw-err" data-err="_" role="alert"></p>
<p class="fn-thanks hidden" id="nw-done" role="status"></p>
<div class="abo-actions nw-actions">
<button type="button" class="abo-btn" id="nw-publish">Aufhängen</button>
<button type="button" class="kb-btn-outline hidden" id="nw-cancel">Abbrechen</button>
</div>
</div>
</div>
<div class="nw-side">
<button type="button" class="kb-btn-outline nw-prev-toggle" id="nw-prev-toggle" aria-expanded="false">So sieht dein Zettel aus</button>
<div class="kb-panel nw-prev" id="nw-prev-box">
<p class="nw-prev-label"><i></i>Vorschau: so sehen es alle</p>
<div id="nw-preview"></div>
</div>
<div class="kb-panel nw-lists" id="nw-lists"><p class="kb-empty">Lade…</p></div>
</div>
</div>`
const $ = (sel) => stage.querySelector(sel)
const $$ = (sel) => [...stage.querySelectorAll(sel)]
const setErr = (field, message) => {
const target = $(`[data-err="${field}"]`) || $('[data-err="_"]')
if (target) target.textContent = message || ''
}
const clearErrs = () => $$('[data-err]').forEach((el) => { el.textContent = '' })
const errField = (field) => ({ imageId: 'photo', date: 'duration' }[field] || field || '_')
function previewHtml() {
const hasPhoto = ed.type !== 'note'
const photo = hasPhoto
? (ed.imageSrc ? `<img src="${esc(ed.imageSrc)}" alt="">` : '<span class="nw-ph">Foto</span>')
: ''
const title = ed.title.trim()
if (ed.type === 'photo') {
return `<figure class="nw-polaroid">${photo}<figcaption><b>${esc(title)}</b><span>Heute</span></figcaption></figure>`
}
const note = `<div class="nw-paper nw-paper-${ed.paper}"><span class="nw-date">Heute</span>${title ? `<h4>${esc(title)}</h4>` : ''}<p>${esc(ed.text)}</p></div>`
return ed.type === 'combo' ? `<div class="nw-combo"><figure class="nw-polaroid">${photo}</figure>${note}</div>` : note
}
function sync() {
$$('.nw-type').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.type === ed.type)))
$$('.nw-sw').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.paper === ed.paper)))
$('#nw-photo-field').classList.toggle('hidden', ed.type === 'note')
$('#nw-text-field').classList.toggle('hidden', ed.type === 'photo')
$('#nw-paper-field').classList.toggle('hidden', ed.type === 'photo')
$('#nw-title-label').textContent = ed.type === 'photo' ? 'Bildunterschrift (freiwillig)' : 'Überschrift (freiwillig)'
$('#nw-count').textContent = `${ed.text.length} / ${TEXT_MAX}`
$('#nw-date').classList.toggle('hidden', ed.duration !== 'date')
$('#nw-step-title').textContent = ed.editId ? 'Zettel ändern' : 'Neuer Zettel'
$('#nw-publish').textContent = ed.editId ? 'Änderungen speichern' : 'Aufhängen'
$('#nw-cancel').classList.toggle('hidden', !ed.editId)
const img = $('#nw-drop-img')
img.classList.toggle('hidden', !ed.imageSrc)
if (ed.imageSrc) img.querySelector('img').src = ed.imageSrc
$('#nw-pick span').textContent = ed.imageSrc ? 'Anderes Foto wählen' : 'Foto auswählen'
$('#nw-publish').disabled = ed.uploading
$('#nw-preview').innerHTML = previewHtml()
}
function reset() {
ed = fresh()
$('#nw-title').value = ''
$('#nw-text').value = ''
$('#nw-duration').value = '1m'
$('#nw-date').value = ''
clearErrs()
sync()
}
// Formular
$$('.nw-type').forEach((b) => b.addEventListener('click', () => { ed.type = b.dataset.type; sync() }))
$$('.nw-sw').forEach((b) => b.addEventListener('click', () => { ed.paper = b.dataset.paper; sync() }))
$('#nw-title').addEventListener('input', (ev) => { ed.title = ev.target.value; sync() })
$('#nw-text').addEventListener('input', (ev) => { ed.text = ev.target.value; sync() })
$('#nw-duration').addEventListener('change', (ev) => { ed.duration = ev.target.value; sync() })
$('#nw-date').addEventListener('change', (ev) => { ed.date = ev.target.value })
$('#nw-prev-toggle').addEventListener('click', (ev) => {
const open = $('#nw-prev-box').classList.toggle('is-open')
ev.currentTarget.setAttribute('aria-expanded', String(open))
})
$('#nw-cancel').addEventListener('click', reset)
// Foto: gleich hochladen, der Server liefert die imageId
$$('#nw-photo-field input[type=file]').forEach((input) => input.addEventListener('change', async () => {
const file = input.files[0]
input.value = ''
if (!file) return
setErr('photo', '')
if (file.size > PHOTO_MAX_BYTES) { setErr('photo', 'Das Foto ist größer als 10 MB.'); return }
const previous = { imageId: ed.imageId, imageSrc: ed.imageSrc }
ed.uploading = true
ed.imageSrc = URL.createObjectURL(file)
sync()
$('#nw-photo-hint').textContent = 'Foto wird hochgeladen…'
try {
const { imageId } = await request(`/api/news/image?${q}`, { method: 'POST', raw: file })
ed.imageId = imageId
} catch (err) {
setErr('photo', err.message)
Object.assign(ed, previous)
} finally {
ed.uploading = false
$('#nw-photo-hint').textContent = 'JPEG, PNG, WebP oder iPhone-Foto, bis 10 MB.'
sync()
}
}))
$('#nw-publish').addEventListener('click', async () => {
clearErrs()
$('#nw-done').classList.add('hidden')
const body = { type: ed.type, title: ed.title.trim(), text: ed.type === 'photo' ? '' : ed.text, paper: ed.paper, duration: ed.duration }
if (ed.duration === 'date') body.date = ed.date
if (ed.type !== 'note') body.imageId = ed.imageId
const wasEdit = Boolean(ed.editId)
const button = $('#nw-publish')
button.disabled = true
try {
if (wasEdit) await request(`/api/news/${encodeURIComponent(ed.editId)}?${q}`, { method: 'PATCH', body })
else await request('/api/news', { method: 'POST', body: { ...body, projectId: pid } })
reset()
const done = $('#nw-done')
done.textContent = wasEdit ? 'Gespeichert.' : 'Hängt jetzt an deiner Pinnwand.'
done.classList.remove('hidden')
await loadList()
} catch (err) {
setErr(errField(err.field), err.message)
} finally {
button.disabled = ed.uploading
}
})
// Listen "Hängt gerade" / "Abgelaufen"
const untilText = (p) => (p.visibleUntil ? `bis ${fmtDay(p.visibleUntil)}` : 'bis du sie abnimmst')
function rowHtml(p, expired) {
const label = p.title || (p.text ? p.text.slice(0, 60) : 'Foto')
const thumb = p.imageId
? `<img class="nw-thumb" src="${esc(imageUrl(p.imageId))}" alt="">`
: `<span class="nw-thumb nw-paper-${esc(p.paper)}">T</span>`
const meta = expired
? (p.takenDownAt ? `Abgenommen am ${fmtDay(p.takenDownAt)}` : `Abgelaufen am ${fmtDay(p.visibleUntil)}`)
: `Seit ${fmtDay(p.visibleFrom)} · ${untilText(p)}`
const actions = expired
? `<select class="nw-rehang-dur" aria-label="Anzeigedauer">${REHANG_DURATIONS.map(([k, l]) => `<option value="${k}"${k === '1m' ? ' selected' : ''}>${l}</option>`).join('')}</select>
<button type="button" data-rehang>Wieder aufhängen</button>`
: '<button type="button" data-edit>Ändern</button><button type="button" data-takedown>Abnehmen</button>'
return `
<li class="nw-row" data-id="${esc(p.$id)}">
${thumb}
<div class="nw-row-text"><b>${esc(label)}</b><small>${esc(meta)}</small></div>
<div class="nw-row-actions">${actions}<button type="button" class="nw-del" data-del>Löschen</button></div>
<p class="nw-err nw-row-err" role="alert"></p>
</li>`
}
function renderLists() {
const box = $('#nw-lists')
const site = safeUrl(liveUrl)
box.innerHTML = `
<div class="nw-lists-head">
<h3>Hängt gerade</h3>
${site ? `<a class="kb-btn-outline" href="${esc(site)}" target="_blank" rel="noopener">Auf meiner Website ansehen</a>` : ''}
</div>
${list.hanging.length ? `<ul class="nw-rows">${list.hanging.map((p) => rowHtml(p, false)).join('')}</ul>` : '<p class="nw-empty">Im Moment hängt nichts. Schreib deinen ersten Zettel links.</p>'}
${list.expired.length ? `<h3 class="nw-expired-title">Abgelaufen</h3><p class="nw-hint">Nicht mehr auf deiner Website zu sehen. Du kannst sie wieder aufhängen.</p><ul class="nw-rows">${list.expired.map((p) => rowHtml(p, true)).join('')}</ul>` : ''}`
box.querySelectorAll('.nw-row').forEach(wireRow)
}
function wireRow(row) {
const id = row.dataset.id
const all = [...list.hanging, ...list.expired]
const post = all.find((p) => p.$id === id)
const rowErr = row.querySelector('.nw-row-err')
const run = async (fn) => {
rowErr.textContent = ''
try {
await fn()
await loadList()
} catch (err) {
rowErr.textContent = err.message
}
}
row.querySelector('[data-edit]')?.addEventListener('click', () => startEdit(post))
row.querySelector('[data-takedown]')?.addEventListener('click', () => run(() => request(`/api/news/${encodeURIComponent(id)}?${q}`, { method: 'PATCH', body: { action: 'takeDown' } })))
row.querySelector('[data-rehang]')?.addEventListener('click', () => {
const duration = row.querySelector('.nw-rehang-dur').value
run(() => request(`/api/news/${encodeURIComponent(id)}?${q}`, { method: 'PATCH', body: { action: 'rehang', duration } }))
})
// Loeschen mit Rueckfrage in der Zeile (kein confirm())
row.querySelector('[data-del]').addEventListener('click', () => {
const actions = row.querySelector('.nw-row-actions')
actions.innerHTML = '<span class="nw-confirm"><span>Wirklich löschen?</span><button type="button" class="nw-del" data-yes>Ja, löschen</button><button type="button" data-cancel>Abbrechen</button></span>'
actions.querySelector('[data-cancel]').addEventListener('click', renderLists)
actions.querySelector('[data-yes]').addEventListener('click', () => run(async () => {
await request(`/api/news/${encodeURIComponent(id)}?${q}`, { method: 'DELETE' })
if (ed.editId === id) reset()
}))
})
}
function startEdit(post) {
clearErrs()
$('#nw-done').classList.add('hidden')
ed = {
editId: post.$id, type: post.type, title: post.title || '', text: post.text || '', paper: post.paper,
duration: post.duration, date: post.duration === 'date' && post.visibleUntil ? new Date(post.visibleUntil).toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' }) : '',
imageId: post.imageId || '', imageSrc: post.imageId ? imageUrl(post.imageId, 'l') : '', uploading: false,
}
$('#nw-title').value = ed.title
$('#nw-text').value = ed.text
$('#nw-duration').value = ed.duration
$('#nw-date').value = ed.date
sync()
$('#nw-step-title').scrollIntoView({ block: 'start', behavior: 'smooth' })
}
async function loadList() {
try {
const data = await request(`/api/news?${q}`)
list = { hanging: data.hanging || [], expired: data.expired || [] }
liveUrl = data.liveUrl || ''
renderLists()
} catch (err) {
$('#nw-lists').innerHTML = `<p class="muted">Beiträge konnten nicht geladen werden: ${esc(err.message)}</p>`
}
}
sync()
return loadList()
}