banyaro/backend/static/js/pages/forum.js
rene 5141ba9969 Session 2026-04-20: Medien-Konvertierung, Umami Analytics, Username/Privacy
- HEIC→JPEG, MOV/AVI→MP4 Konvertierung bei allen Upload-Endpoints (media_utils.py)
- ffmpeg im Docker-Image, Video-Thumbnails (extract_video_thumb, poster-Attribut)
- Google Analytics entfernt, Umami self-hosted eingebunden (index.html, datenschutz.js)
- Admin-Panel Analytics-Tab: Stat-Cards, Sparkline 7 Tage, Top-Seiten (Umami-API-Proxy)
- Admin-Panel Tab-Icons korrigiert (aus vorhandenem Phosphor-Sprite)
- users.real_name Spalte: Username öffentlich, echter Name privat und optional
- Registrierung: Label "Benutzername", Leerzeichen verboten, Profanity-Blockliste
- Datenschutzerklärung: GA-Abschnitt durch Umami-Text ersetzt
2026-04-20 18:36:58 +02:00

1221 lines
52 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.

/* ============================================================
BAN YARO — Forum (Sprint 11)
Kategorien, Threads, Antworten, Likes, Reports,
Foto-Upload, Mitgliederkarte, Moderations-Panel
============================================================ */
window.Page_forum = (() => {
let _container = null;
let _appState = null;
let _threads = [];
let _aktivKat = 'alle';
let _offset = 0;
let _searchTimer = null;
let _searching = false;
let _mapLoaded = false;
let _leafletLoaded = false;
let _map = null;
let _clusterGroup = null;
let _activeSection = 'list'; // 'list' | 'map'
const LIMIT = 30;
const KATEGORIEN = [
{ key: 'alle', label: 'Alle' },
{ key: 'allgemein', label: 'Allgemein' },
{ key: 'rasse', label: 'Rasse' },
{ key: 'region', label: 'Region' },
{ key: 'gesundheit', label: 'Gesundheit' },
{ key: 'erziehung', label: 'Erziehung' },
{ key: 'spaziergang', label: 'Spaziergang' },
{ key: 'ausflug', label: 'Ausflug' },
{ key: 'training', label: 'Training & Lektionen' },
{ key: 'ernaehrung', label: 'Ernährung & Rezepte' },
{ key: 'probleme', label: 'Probleme' },
{ key: 'tauschboerse', label: 'Tauschbörse' },
];
// ----------------------------------------------------------
// Helpers
// ----------------------------------------------------------
function _esc(s) {
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function _fmtDate(iso) {
if (!iso) return '—';
const d = new Date(iso);
const now = new Date();
const diff = (now - d) / 1000;
if (diff < 60) return 'gerade eben';
if (diff < 3600) return `vor ${Math.floor(diff / 60)} Min`;
if (diff < 86400) return `vor ${Math.floor(diff / 3600)} Std`;
return d.toLocaleDateString('de-DE');
}
function _initial(name) {
return (name || '?').charAt(0).toUpperCase();
}
// ----------------------------------------------------------
// INIT
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
_render();
_loadThreads(true);
}
function refresh() {
_loadThreads(true);
}
function onDogChange() {}
// ----------------------------------------------------------
// RENDER — Grundstruktur
// ----------------------------------------------------------
function _render() {
const isMod = !!_appState.user?.is_moderator;
_container.innerHTML = `
<div class="forum-layout">
<!-- Header -->
<div class="forum-header">
<h2 class="forum-header-title">Forum</h2>
<div class="forum-header-actions">
${isMod ? `<button class="btn btn-ghost btn-sm" id="forum-mod-btn" title="Moderationsberichte">${UI.icon('warning')}</button>` : ''}
<button class="btn btn-ghost btn-sm" id="forum-rules-btn" title="Regeln & Netiquette" style="color:var(--c-text-muted)">${UI.icon('info')} Regeln</button>
<button class="btn btn-primary btn-sm" id="forum-new-btn">${UI.icon('plus')} Neues Thema</button>
</div>
</div>
<!-- Kategorie-Tabs -->
<div class="forum-category-tabs by-tabs" id="forum-tabs">
${KATEGORIEN.map(k => `
<button class="by-tab ${k.key === _aktivKat ? 'active' : ''}"
data-kat="${k.key}">${_esc(k.label)}</button>
`).join('')}
<button class="by-tab ${_activeSection === 'map' ? 'active' : ''}"
data-section="map">${UI.icon('users')} Mitgliederkarte</button>
</div>
<!-- Suchleiste -->
<div class="forum-search-wrap">
<input type="search" class="forum-search" id="forum-search"
placeholder="Forum durchsuchen…" autocomplete="off">
</div>
<!-- Thread-Liste / Karte / Suche -->
<div id="forum-main">
<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Lädt…</p>
</div>
</div>
`;
// Tab-Klicks
document.getElementById('forum-tabs').addEventListener('click', e => {
const btn = e.target.closest('[data-kat], [data-section]');
if (!btn) return;
if (btn.dataset.section === 'map') {
_aktivKat = 'alle';
_activeSection = 'map';
document.querySelectorAll('#forum-tabs .by-tab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_renderMembersMap();
return;
}
_aktivKat = btn.dataset.kat;
_activeSection = 'list';
document.querySelectorAll('#forum-tabs .by-tab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_offset = 0;
_threads = [];
_loadThreads(true);
});
// Suche
document.getElementById('forum-search').addEventListener('input', e => {
clearTimeout(_searchTimer);
const q = e.target.value.trim();
if (!q) {
_searching = false;
_renderList();
return;
}
_searchTimer = setTimeout(() => _doSearch(q), 400);
});
// Neues Thema
document.getElementById('forum-new-btn').addEventListener('click', () => {
if (!_appState.user) { UI.toast.info('Bitte erst anmelden.'); return; }
_showCreateForm();
});
// Moderations-Panel
document.getElementById('forum-mod-btn')?.addEventListener('click', _showModPanel);
// Regeln & Netiquette
document.getElementById('forum-rules-btn').addEventListener('click', _showRules);
}
// ----------------------------------------------------------
// Threads laden
// ----------------------------------------------------------
async function _loadThreads(reset = false) {
if (reset) { _offset = 0; _threads = []; }
const mainEl = document.getElementById('forum-main');
if (mainEl && reset) {
mainEl.innerHTML = `<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Lädt…</p>`;
}
try {
const params = { limit: LIMIT, offset: _offset };
if (_aktivKat !== 'alle') params.kategorie = _aktivKat;
const rows = await API.forum.threads(params);
_threads = reset ? rows : [..._threads, ...rows];
_offset += rows.length;
_renderList(rows.length < LIMIT);
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Laden.');
}
}
// ----------------------------------------------------------
// Thread-Liste rendern
// ----------------------------------------------------------
function _renderList(noMore = false) {
if (_searching) return;
const el = document.getElementById('forum-main');
if (!el) return;
if (!_threads.length) {
el.innerHTML = `
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
<div style="font-size:3rem;margin-bottom:var(--space-3)">${UI.icon('chat-circle-dots')}</div>
<p style="color:var(--c-text-secondary)">Noch keine Beiträge in dieser Kategorie.</p>
<button class="btn btn-primary" style="margin-top:var(--space-4)" id="forum-first-btn">
Ersten Beitrag erstellen
</button>
</div>`;
document.getElementById('forum-first-btn')?.addEventListener('click', () => {
if (!_appState.user) { UI.toast.info('Bitte erst anmelden.'); return; }
_showCreateForm();
});
return;
}
el.innerHTML = `
<div class="forum-list-inner" id="forum-thread-list">
${_threads.map(_threadCardHTML).join('')}
</div>
${!noMore ? `<div style="text-align:center;padding:var(--space-4)">
<button class="btn btn-secondary btn-sm" id="forum-loadmore">Mehr laden</button>
</div>` : ''}
`;
el.querySelectorAll('.forum-thread-card').forEach(card => {
card.addEventListener('click', () => _openThread(parseInt(card.dataset.id)));
});
document.getElementById('forum-loadmore')?.addEventListener('click', () => {
_loadThreads(false);
});
}
function _threadCardHTML(t) {
const preview = t.text_preview
? _esc(t.text_preview.slice(0, 120)) + (t.text_preview.length >= 120 ? '…' : '')
: '';
const pinBadge = t.is_pinned ? `<span class="forum-pin-badge" title="Angepinnt">${UI.icon('push-pin')}</span>` : '';
const lockBadge = t.is_locked ? `<span class="forum-lock-badge" title="Gesperrt">${UI.icon('lock')}</span>` : '';
const fotoHtml = t.foto_preview
? /\.(mp4|mov|webm|m4v|avi)$/i.test(t.foto_preview)
? `<div class="forum-card-thumb forum-card-thumb--video" style="display:flex;align-items:center;justify-content:center;background:var(--c-surface-2)">${UI.icon('video-camera')}</div>`
: `<img class="forum-card-thumb" src="${_esc(t.foto_preview)}" alt="" loading="lazy">`
: '';
return `
<div class="forum-thread-card" data-id="${t.id}">
<div class="forum-card-top">
<span class="forum-category-badge forum-category-badge--${_esc(t.kategorie)}">${_esc(t.kategorie)}</span>
${pinBadge}${lockBadge}
</div>
<div class="forum-card-content">
<div class="forum-card-main">
<div class="forum-card-title">${_esc(t.titel)}</div>
${preview ? `<div class="forum-card-preview">${preview}</div>` : ''}
<div class="forum-card-meta">
<span>${UI.icon('user')} ${_esc(t.autor_name || 'Unbekannt')}</span>
<span>${UI.icon('calendar-dots')} ${_fmtDate(t.created_at)}</span>
<span>${UI.icon('chat-circle-dots')} ${t.antworten || 0}</span>
<span class="${t.user_liked ? 'forum-liked' : ''}">${UI.icon('heart')} ${t.likes || 0}</span>
</div>
</div>
${fotoHtml}
</div>
</div>`;
}
// ----------------------------------------------------------
// Suche
// ----------------------------------------------------------
async function _doSearch(q) {
_searching = true;
const el = document.getElementById('forum-main');
if (el) el.innerHTML = `<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-6)">Suche…</p>`;
try {
const results = await API.forum.search(q);
if (!document.getElementById('forum-main')) return;
if (!results.length) {
document.getElementById('forum-main').innerHTML = `
<div style="text-align:center;padding:var(--space-8)">
<div style="font-size:2rem;margin-bottom:var(--space-2)">${UI.icon('magnifying-glass')}</div>
<p style="color:var(--c-text-secondary)">Keine Ergebnisse für „${_esc(q)}"</p>
</div>`;
return;
}
document.getElementById('forum-main').innerHTML = `
<div class="forum-list-inner">
${results.map(t => _threadCardHTML({ ...t, foto_preview: null, is_pinned: 0, is_locked: 0, user_liked: false })).join('')}
</div>`;
document.getElementById('forum-main').querySelectorAll('.forum-thread-card').forEach(card => {
card.addEventListener('click', () => _openThread(parseInt(card.dataset.id)));
});
} catch (err) {
UI.toast.error(err.message || 'Suchfehler.');
}
}
// ----------------------------------------------------------
// Thread-Detail-Modal
// ----------------------------------------------------------
async function _openThread(threadId) {
let thread;
try {
thread = await API.forum.thread(threadId);
} catch (err) {
UI.toast.error(err.message);
return;
}
const uid = _appState.user?.id;
const isMod = !!_appState.user?.is_moderator;
const isOwn = uid && uid === thread.user_id;
const modToolbar = (isMod) ? `
<div class="forum-mod-toolbar">
<button class="btn btn-ghost btn-sm forum-mod-pin" title="${thread.is_pinned ? 'Unpin' : 'Anpinnen'}">
${UI.icon('push-pin')} ${thread.is_pinned ? 'Unpin' : 'Pin'}
</button>
<button class="btn btn-ghost btn-sm forum-mod-lock" title="${thread.is_locked ? 'Entsperren' : 'Sperren'}">
${UI.icon('lock')} ${thread.is_locked ? 'Entsperren' : 'Sperren'}
</button>
<button class="btn btn-ghost btn-sm forum-mod-delete-thread" style="color:var(--c-danger)">${UI.icon('trash')} Thread</button>
</div>` : '';
const _forumMediaHtml = (u) => {
if (u.endsWith('.pdf'))
return `<a href="${_esc(u)}" target="_blank" rel="noopener" class="forum-pdf-card">
${UI.icon('file-text')} <span>${_esc(u.split('/').pop())}</span></a>`;
if (/\.(mp4|mov|webm|m4v|avi)$/i.test(u)) {
const poster = u.replace(/\.[^.]+$/, '_thumb.jpg');
return `<video src="${_esc(u)}" poster="${_esc(poster)}" controls playsinline
style="max-width:100%;max-height:320px;border-radius:var(--radius-md);display:block"></video>`;
}
return `<img src="${_esc(u)}" class="forum-foto-img" data-src="${_esc(u)}" alt="" loading="lazy">`;
};
const fotoGallery = (thread.foto_urls?.length)
? `<div class="forum-foto-grid">${thread.foto_urls.map(_forumMediaHtml).join('')}</div>`
: '';
const likeClass = thread.user_liked ? 'forum-like-btn active' : 'forum-like-btn';
const postsHtml = (thread.posts?.length)
? thread.posts.map(p => _postHTML(p, uid, isMod)).join('')
: `<p style="color:var(--c-text-muted);font-style:italic;padding:var(--space-3) 0">Noch keine Antworten.</p>`;
const replySection = _appState.user && !thread.is_locked ? `
<div class="forum-reply-form">
<label class="form-label">Antwort schreiben</label>
<textarea class="form-control" id="forum-reply-text" rows="3"
placeholder="Deine Antwort…"></textarea>
<div class="forum-reply-actions">
<label class="btn btn-ghost btn-sm forum-upload-label" title="Foto anhängen">
${UI.icon('camera')}
<input type="file" accept="image/*" id="forum-reply-file" style="display:none">
</label>
<div id="forum-reply-previews" class="forum-upload-previews"></div>
</div>
</div>` : (!_appState.user
? `<p style="color:var(--c-text-muted);font-size:0.85rem;margin-top:var(--space-3)">Bitte anmelden um zu antworten.</p>`
: `<p style="color:var(--c-text-muted);font-size:0.85rem;margin-top:var(--space-3)">${UI.icon('lock')} Dieser Thread ist gesperrt.</p>`
);
const body = `
<div class="forum-thread-detail">
${modToolbar}
<div class="forum-thread-header-row">
<span class="forum-category-badge forum-category-badge--${_esc(thread.kategorie)}">${_esc(thread.kategorie)}</span>
<span style="color:var(--c-text-muted);font-size:0.8rem">${_fmtDate(thread.created_at)}</span>
${thread.is_pinned ? `<span>${UI.icon('push-pin')}</span>` : ''}
${thread.is_locked ? `<span>${UI.icon('lock')}</span>` : ''}
</div>
<div class="forum-thread-body">
<p style="white-space:pre-wrap;word-break:break-word">${_esc(thread.text)}</p>
${fotoGallery}
</div>
<div class="forum-thread-author-row">
<div class="forum-avatar">${_esc(_initial(thread.autor_name))}</div>
<span style="font-size:0.85rem;color:var(--c-text-secondary)">${_esc(thread.autor_name || 'Unbekannt')}</span>
<div style="margin-left:auto;display:flex;gap:var(--space-2);align-items:center">
<button class="${likeClass}" id="thread-like-btn" data-count="${thread.likes || 0}">
${UI.icon('heart')} <span id="thread-like-count">${thread.likes || 0}</span>
</button>
${_appState.user && !isOwn ? `<button class="forum-report-btn" id="thread-report-btn">${UI.icon('flag')}</button>` : ''}
</div>
</div>
<div class="forum-posts-section">
<div class="forum-posts-divider">${thread.antworten || 0} Antworten</div>
<div id="forum-posts-list">${postsHtml}</div>
</div>
${replySection}
</div>
`;
const footer = _appState.user ? `
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
<div style="display:flex;gap:var(--space-2);align-items:center">
${(isOwn || isMod) ? `<button type="button" class="forum-icon-btn forum-icon-btn--danger" id="ft-delete-thread" title="Löschen">${UI.icon('trash')}</button>` : ''}
${isOwn ? `<button type="button" class="forum-icon-btn" id="ft-edit-thread" title="Bearbeiten">${UI.icon('pencil-simple')}</button>` : ''}
<div style="margin-left:auto;display:flex;gap:var(--space-2)">
<button type="button" class="btn btn-secondary btn-sm" id="ft-close">Schließen</button>
${(!thread.is_locked && _appState.user) ? `<button type="button" class="btn btn-primary btn-sm" id="ft-reply">Antworten</button>` : ''}
</div>
</div>
</div>
` : `<button type="button" class="btn btn-primary w-full" id="ft-close">Schließen</button>`;
UI.modal.open({ title: `${UI.icon('chat-circle-dots')} ${_esc(thread.titel)}`, body, footer });
// Close
document.getElementById('ft-close')?.addEventListener('click', UI.modal.close);
// Like thread
document.getElementById('thread-like-btn')?.addEventListener('click', async () => {
if (!_appState.user) { UI.toast.info('Bitte erst anmelden.'); return; }
const btn = document.getElementById('thread-like-btn');
try {
const res = await API.forum.like('thread', thread.id);
thread.user_liked = res.liked;
thread.likes = res.count;
btn.classList.toggle('active', res.liked);
const countEl = document.getElementById('thread-like-count');
if (countEl) countEl.textContent = res.count;
// Update local state
const idx = _threads.findIndex(t => t.id === thread.id);
if (idx !== -1) { _threads[idx].likes = res.count; _threads[idx].user_liked = res.liked; }
} catch (err) { UI.toast.error(err.message); }
});
// Report thread
document.getElementById('thread-report-btn')?.addEventListener('click', () => {
_showReportForm('thread', thread.id);
});
// Delete thread
document.getElementById('ft-delete-thread')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({
title: 'Thread löschen?',
message: 'Der Thread wird unwiderruflich entfernt.',
confirmText: 'Löschen', danger: true,
});
if (!ok) return;
try {
await API.forum.deleteThread(thread.id);
_threads = _threads.filter(t => t.id !== thread.id);
UI.modal.close();
_renderList(true);
UI.toast.success('Thread gelöscht.');
} catch (err) { UI.toast.error(err.message); }
});
// Edit thread (owner)
document.getElementById('ft-edit-thread')?.addEventListener('click', () => {
_showEditThreadModal(thread);
});
// Moderator: pin/lock/delete
document.querySelector('.forum-mod-pin')?.addEventListener('click', async () => {
try {
await API.forum.patchThread(thread.id, { is_pinned: thread.is_pinned ? 0 : 1 });
UI.toast.success('Gespeichert.');
UI.modal.close();
_loadThreads(true);
} catch (err) { UI.toast.error(err.message); }
});
document.querySelector('.forum-mod-lock')?.addEventListener('click', async () => {
try {
await API.forum.patchThread(thread.id, { is_locked: thread.is_locked ? 0 : 1 });
UI.toast.success('Gespeichert.');
UI.modal.close();
_loadThreads(true);
} catch (err) { UI.toast.error(err.message); }
});
document.querySelector('.forum-mod-delete-thread')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({ title: 'Thread löschen?', message: 'Moderator-Löschung.', confirmText: 'Löschen', danger: true });
if (!ok) return;
try {
await API.forum.deleteThread(thread.id);
_threads = _threads.filter(t => t.id !== thread.id);
UI.modal.close();
_renderList(true);
UI.toast.success('Thread gelöscht.');
} catch (err) { UI.toast.error(err.message); }
});
// Foto-Vollbild
document.getElementById('modal-container')?.querySelectorAll('.forum-foto-img').forEach(img => {
img.addEventListener('click', () => _showLightbox(img.dataset.src || img.src));
});
// Reply file preview
const replyFileInput = document.getElementById('forum-reply-file');
replyFileInput?.addEventListener('change', () => {
const previews = document.getElementById('forum-reply-previews');
if (!previews) return;
previews.innerHTML = '';
const files = Array.from(replyFileInput.files || []);
files.slice(0, 5).forEach(file => {
const url = URL.createObjectURL(file);
const img = document.createElement('img');
img.src = url;
img.className = 'forum-upload-thumb';
previews.appendChild(img);
});
});
// Post-Löschen-Buttons binden
const postsListEl = document.getElementById('forum-posts-list');
if (postsListEl) _bindPostActions(postsListEl, thread.id, uid, isMod);
// Reply abschicken
document.getElementById('ft-reply')?.addEventListener('click', async () => {
const btn = document.getElementById('ft-reply');
const text = document.getElementById('forum-reply-text')?.value?.trim();
if (!text) { UI.toast.warning('Bitte Text eingeben.'); return; }
await UI.asyncButton(btn, async () => {
const post = await API.forum.addPost(thread.id, { text });
// Foto hochladen falls vorhanden
const files = Array.from(document.getElementById('forum-reply-file')?.files || []);
for (const file of files.slice(0, 5)) {
try {
await API.forum.uploadPostFoto(post.id, file);
} catch (e) { /* Foto-Upload-Fehler ignorieren */ }
}
thread.antworten = (thread.antworten || 0) + 1;
const idx = _threads.findIndex(t => t.id === thread.id);
if (idx !== -1) _threads[idx].antworten = thread.antworten;
const listEl = document.getElementById('forum-posts-list');
if (listEl) {
const placeholder = listEl.querySelector('p[style*="italic"]');
if (placeholder) listEl.innerHTML = '';
listEl.insertAdjacentHTML('beforeend', _postHTML(post, uid, isMod));
_bindPostActions(listEl, thread.id, uid, isMod);
listEl.lastElementChild?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
document.getElementById('forum-reply-text').value = '';
const previews = document.getElementById('forum-reply-previews');
if (previews) previews.innerHTML = '';
UI.toast.success('Antwort gesendet.');
});
});
}
// ----------------------------------------------------------
// Post HTML
// ----------------------------------------------------------
function _postHTML(p, uid, isMod) {
if (p.is_deleted) {
return `<div class="forum-post forum-post--deleted" data-post-id="${p.id}">
<em>Beitrag wurde entfernt</em>
</div>`;
}
const isOwn = uid && uid === p.user_id;
const fotoHtml = (p.foto_urls?.length)
? `<div class="forum-foto-grid">${p.foto_urls.map(u =>
`<img src="${_esc(u)}" class="forum-foto-img" data-src="${_esc(u)}" alt="" loading="lazy">`
).join('')}</div>`
: '';
const likeClass = p.user_liked ? 'forum-like-btn active' : 'forum-like-btn';
const canDelete = isOwn || isMod;
return `
<div class="forum-post" data-post-id="${p.id}" data-user-id="${p.user_id || ''}">
<div class="forum-post-header">
<div class="forum-avatar forum-avatar--sm">${_esc(_initial(p.autor_name))}</div>
<span class="forum-post-author">${_esc(p.autor_name || 'Unbekannt')}</span>
<span class="forum-post-date">${_fmtDate(p.created_at)}</span>
</div>
<div class="forum-post-body">
<div class="forum-post-text">${_esc(p.text)}</div>
${fotoHtml}
</div>
<div class="forum-post-actions">
<button class="${likeClass} forum-post-like" data-post-id="${p.id}">
${UI.icon('heart')} <span class="forum-post-like-count">${p.likes || 0}</span>
</button>
${(!isOwn && uid) ? `<button class="forum-icon-btn forum-post-report" data-post-id="${p.id}" title="Melden">${UI.icon('flag')}</button>` : ''}
<div style="margin-left:auto;display:flex;gap:4px">
${isOwn ? `<button class="forum-icon-btn forum-post-edit" data-post-id="${p.id}" data-text="${_esc(p.text || '')}" title="Bearbeiten">${UI.icon('pencil-simple')}</button>` : ''}
${canDelete ? `<button class="forum-icon-btn forum-icon-btn--danger forum-post-delete" data-post-id="${p.id}" title="Löschen">${UI.icon('trash')}</button>` : ''}
</div>
</div>
</div>`;
}
// ----------------------------------------------------------
// Post-Aktionen binden
// ----------------------------------------------------------
function _bindPostActions(container, threadId, uid, isMod) {
// Like
container.querySelectorAll('.forum-post-like:not([data-bound])').forEach(btn => {
btn.dataset.bound = '1';
btn.addEventListener('click', async () => {
if (!uid) { UI.toast.info('Bitte erst anmelden.'); return; }
const postId = parseInt(btn.dataset.postId);
try {
const res = await API.forum.like('post', postId);
btn.classList.toggle('active', res.liked);
const countEl = btn.querySelector('.forum-post-like-count');
if (countEl) countEl.textContent = res.count;
} catch (err) { UI.toast.error(err.message); }
});
});
// Report
container.querySelectorAll('.forum-post-report:not([data-bound])').forEach(btn => {
btn.dataset.bound = '1';
btn.addEventListener('click', () => {
_showReportForm('post', parseInt(btn.dataset.postId));
});
});
// Edit (owner)
container.querySelectorAll('.forum-post-edit:not([data-bound])').forEach(btn => {
btn.dataset.bound = '1';
btn.addEventListener('click', () => {
const postId = parseInt(btn.dataset.postId);
const currentText = btn.dataset.text || '';
_showEditPostModal(postId, currentText, container, threadId, uid, isMod);
});
});
// Delete
container.querySelectorAll('.forum-post-delete:not([data-bound])').forEach(btn => {
btn.dataset.bound = '1';
btn.addEventListener('click', async () => {
const postId = parseInt(btn.dataset.postId);
const postEl = container.querySelector(`[data-post-id="${postId}"]`);
const ok = await UI.modal.confirm({
title: 'Antwort löschen?',
message: 'Dieser Beitrag wird entfernt.',
confirmText: 'Löschen', danger: true,
});
if (!ok) return;
try {
await API.forum.deletePost(postId);
if (postEl) {
postEl.innerHTML = '<em style="color:var(--c-text-muted)">Beitrag wurde entfernt</em>';
postEl.className = 'forum-post forum-post--deleted';
}
const idx = _threads.findIndex(t => t.id === threadId);
if (idx !== -1 && _threads[idx].antworten > 0) _threads[idx].antworten--;
UI.toast.success('Beitrag gelöscht.');
} catch (err) { UI.toast.error(err.message); }
});
});
// Foto-Fullscreen
container.querySelectorAll('.forum-foto-img:not([data-bound])').forEach(img => {
img.dataset.bound = '1';
img.addEventListener('click', () => _showLightbox(img.dataset.src || img.src));
});
}
// ----------------------------------------------------------
// Report-Formular
// ----------------------------------------------------------
function _showReportForm(targetType, targetId) {
const body = `
<form id="forum-report-form">
<div class="form-group">
<label class="form-label">Grund der Meldung</label>
<select class="form-control" name="grund">
<option value="spam">Spam</option>
<option value="beleidigung">Beleidigung / Hassrede</option>
<option value="falschinfo">Falsche Informationen</option>
<option value="sonstiges">Sonstiges</option>
</select>
</div>
</form>`;
const footer = `
<button type="button" class="btn btn-secondary flex-1" id="rep-cancel">Abbrechen</button>
<button type="submit" form="forum-report-form" class="btn btn-danger flex-1">Melden</button>`;
UI.modal.open({ title: `${UI.icon('flag')} Inhalt melden`, body, footer });
document.getElementById('rep-cancel')?.addEventListener('click', UI.modal.close);
document.getElementById('forum-report-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = document.querySelector('[form="forum-report-form"][type="submit"]');
const fd = UI.formData(e.target);
await UI.asyncButton(btn, async () => {
await API.forum.report(targetType, targetId, fd.grund);
UI.modal.close();
UI.toast.success('Gemeldet. Danke!');
});
});
}
// ----------------------------------------------------------
// Neues Thema
// ----------------------------------------------------------
// Regeln & Netiquette
// ----------------------------------------------------------
function _showRules() {
UI.modal.open({
title: `${UI.icon('info')} Regeln & Netiquette`,
body: `
<div style="display:flex;flex-direction:column;gap:var(--space-4)">
<div>
<p style="color:var(--c-text-secondary);font-size:var(--text-sm);margin-bottom:var(--space-3)">
Das Ban-Yaro-Forum ist ein Ort für Hundehalter — freundlich, hilfsbereit und respektvoll.
Bitte halte diese Grundregeln ein, damit es für alle schön bleibt.
</p>
</div>
<div>
<div style="font-weight:var(--weight-semibold);margin-bottom:var(--space-2);display:flex;align-items:center;gap:var(--space-2)">
${UI.icon('chat-circle-dots')} Ton & Umgang
</div>
<ul style="list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:var(--space-1);font-size:var(--text-sm);color:var(--c-text-secondary)">
<li>${UI.icon('check')} Freundlich und respektvoll bleiben — auch bei verschiedenen Meinungen</li>
<li>${UI.icon('check')} Konstruktive Kritik statt persönliche Angriffe</li>
<li>${UI.icon('check')} Andere Haltungsmethoden tolerieren — solange der Hund nicht leidet</li>
<li>${UI.icon('prohibit')} Keine Beleidigungen, Drohungen oder Diskriminierung</li>
</ul>
</div>
<div>
<div style="font-weight:var(--weight-semibold);margin-bottom:var(--space-2);display:flex;align-items:center;gap:var(--space-2)">
${UI.icon('files')} Inhalte
</div>
<ul style="list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:var(--space-1);font-size:var(--text-sm);color:var(--c-text-secondary)">
<li>${UI.icon('check')} Thema passend zur gewählten Kategorie</li>
<li>${UI.icon('check')} Aussagekräftiger Titel — kein "Hilfe!!!" ohne Kontext</li>
<li>${UI.icon('prohibit')} Keine Werbung, Spam oder Affiliate-Links</li>
<li>${UI.icon('prohibit')} Keine Doppelposts — bestehenden Thread suchen bevor du neu erstellst</li>
</ul>
</div>
<div>
<div style="font-weight:var(--weight-semibold);margin-bottom:var(--space-2);display:flex;align-items:center;gap:var(--space-2)">
${UI.icon('syringe')} Gesundheit & Notfälle
</div>
<ul style="list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:var(--space-1);font-size:var(--text-sm);color:var(--c-text-secondary)">
<li>${UI.icon('check')} Erfahrungen teilen ist wertvoll — bitte immer den Tierarzt empfehlen</li>
<li>${UI.icon('prohibit')} Keine Diagnosen oder Medikamentendosierungen für fremde Hunde</li>
<li>${UI.icon('warning-circle')} Bei Notfällen: direkt zum Tierarzt — nicht erst im Forum fragen</li>
</ul>
</div>
<div>
<div style="font-weight:var(--weight-semibold);margin-bottom:var(--space-2);display:flex;align-items:center;gap:var(--space-2)">
${UI.icon('flag')} Moderation
</div>
<ul style="list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:var(--space-1);font-size:var(--text-sm);color:var(--c-text-secondary)">
<li>${UI.icon('check')} Regelverstoß? Melde-Funktion nutzen statt selbst zu reagieren</li>
<li>${UI.icon('check')} Moderatoren können Beiträge bearbeiten, verstecken oder löschen</li>
<li>${UI.icon('check')} Bei Unklarheiten freundlich nachfragen</li>
</ul>
</div>
<p style="font-size:var(--text-xs);color:var(--c-text-muted);border-top:1px solid var(--c-border-light);padding-top:var(--space-3);margin:0">
Wer wiederholt gegen die Regeln verstößt, kann vorübergehend gesperrt werden.
Das Ziel ist ein freundliches Forum — nicht Kontrolle um der Kontrolle willen. 🐾
</p>
</div>`,
footer: `<button class="btn btn-primary flex-1" onclick="UI.modal.close()">Verstanden</button>`,
});
}
// ----------------------------------------------------------
function _showCreateForm() {
const katOptions = KATEGORIEN.filter(k => k.key !== 'alle').map(k =>
`<option value="${k.key}" ${k.key === _aktivKat ? 'selected' : ''}>${_esc(k.label)}</option>`
).join('');
const body = `
<form id="forum-thread-form" autocomplete="off">
<div class="form-group">
<label class="form-label">Kategorie</label>
<select class="form-control" name="kategorie">${katOptions}</select>
</div>
<div class="form-group">
<label class="form-label">Titel *</label>
<input class="form-control" type="text" name="titel"
placeholder="Worum geht es?" required maxlength="200">
</div>
<div class="form-group">
<label class="form-label">Text * (min. 20 Zeichen)</label>
<textarea class="form-control" name="text" rows="5"
placeholder="Beschreibe dein Thema ausführlich…" required></textarea>
</div>
<div class="form-group">
<label class="form-label">Standort <span style="color:var(--c-text-secondary)">(optional)</span></label>
<div id="forum-location-picker"></div>
</div>
<div class="form-group">
<label class="form-label">Fotos / Dateien (max. 5)</label>
<div class="forum-upload-area">
<label class="btn btn-secondary btn-sm" for="forum-thread-files">${UI.icon('image')} Fotos / Video / PDF</label>
<input type="file" id="forum-thread-files" accept="image/*,video/*,application/pdf" multiple style="display:none">
</div>
<div id="forum-thread-previews" class="forum-upload-previews" style="margin-top:var(--space-2)"></div>
</div>
</form>
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-3)">
${UI.icon('info')} Bitte die
<button type="button" style="background:none;border:none;padding:0;color:var(--c-primary);cursor:pointer;font-size:inherit;text-decoration:underline" id="ff-rules-link">Regeln & Netiquette</button>
beachten.
</p>`;
const footer = `
<button type="button" class="btn btn-secondary flex-1" id="ff-cancel">Abbrechen</button>
<button type="submit" form="forum-thread-form" class="btn btn-primary flex-1">${UI.icon('chat-circle-dots')} Erstellen</button>`;
UI.modal.open({ title: '+ Neues Thema', body, footer });
let _picker = null;
setTimeout(() => {
_picker = UI.locationPicker({ containerId: 'forum-location-picker' });
}, 50);
document.getElementById('ff-cancel')?.addEventListener('click', UI.modal.close);
document.getElementById('ff-rules-link')?.addEventListener('click', _showRules);
// Foto-Vorschau — eigenes Array damit iOS-Mehrfachauswahl akkumuliert
let _threadFiles = [];
const _renderThreadPreviews = () => {
const previews = document.getElementById('forum-thread-previews');
if (!previews) return;
previews.innerHTML = '';
_threadFiles.forEach((file, i) => {
const wrap = document.createElement('div');
wrap.style.cssText = 'position:relative;display:inline-block';
let thumb;
if (file.type === 'application/pdf' || file.name.endsWith('.pdf')) {
thumb = document.createElement('div');
thumb.className = 'forum-upload-thumb';
thumb.style.cssText = 'display:flex;align-items:center;justify-content:center;background:var(--c-surface-2);font-size:11px;color:var(--c-text-secondary);text-align:center;padding:4px';
thumb.textContent = '📄 PDF';
} else if (file.type.startsWith('video/')) {
thumb = document.createElement('video');
thumb.src = URL.createObjectURL(file);
thumb.className = 'forum-upload-thumb';
thumb.muted = true;
} else {
thumb = document.createElement('img');
thumb.src = URL.createObjectURL(file);
thumb.className = 'forum-upload-thumb';
}
const del = document.createElement('button');
del.type = 'button';
del.textContent = '×';
del.style.cssText = 'position:absolute;top:-4px;right:-4px;width:18px;height:18px;border-radius:50%;' +
'background:var(--c-danger);color:#fff;border:none;font-size:12px;line-height:1;cursor:pointer;' +
'display:flex;align-items:center;justify-content:center;padding:0';
del.addEventListener('click', () => { _threadFiles.splice(i, 1); _renderThreadPreviews(); });
wrap.appendChild(thumb);
wrap.appendChild(del);
previews.appendChild(wrap);
});
};
document.getElementById('forum-thread-files')?.addEventListener('change', e => {
const neu = Array.from(e.target.files || []);
neu.forEach(f => {
if (_threadFiles.length < 5) _threadFiles.push(f);
});
e.target.value = ''; // reset damit dieselbe Datei nochmal wählbar ist
_renderThreadPreviews();
});
document.getElementById('forum-thread-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = document.querySelector('[form="forum-thread-form"][type="submit"]');
const fd = UI.formData(e.target);
if ((fd.text || '').trim().length < 20) {
UI.toast.warning('Text muss mindestens 20 Zeichen lang sein.');
return;
}
await UI.asyncButton(btn, async () => {
const loc = _picker ? _picker.getValue() : { lat: null, lon: null, name: null };
const created = await API.forum.create({
kategorie: fd.kategorie,
titel: (fd.titel || '').trim(),
text: (fd.text || '').trim(),
thread_lat: loc.lat ?? null,
thread_lon: loc.lon ?? null,
thread_ort: loc.name ?? null,
});
// Fotos hochladen
for (const file of _threadFiles.slice(0, 5)) {
try {
await API.forum.uploadThreadFoto(created.id, file);
} catch (e) { /* ignorieren */ }
}
_threads.unshift({
...created,
text_preview: created.text?.slice(0, 120) || '',
foto_preview: null,
});
UI.modal.close();
_renderList();
UI.toast.success('Beitrag erstellt!');
document.getElementById('forum-thread-list')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
});
}
// ----------------------------------------------------------
// Mitgliederkarte
// ----------------------------------------------------------
async function _renderMembersMap() {
const el = document.getElementById('forum-main');
if (!el) return;
el.innerHTML = `
<div class="forum-members-section">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-3)">
<strong>Mitglieder auf der Karte</strong>
${_appState.user ? `
<label class="forum-location-toggle">
<input type="checkbox" id="forum-loc-toggle" ${_appState.user.forum_show_location ? 'checked' : ''}>
<span>Meinen (ungefähren) Standort teilen</span>
</label>` : ''}
</div>
<div id="forum-map" class="forum-map-container"></div>
</div>`;
// Location-Toggle
document.getElementById('forum-loc-toggle')?.addEventListener('change', async e => {
const show = e.target.checked;
if (show) {
try {
const pos = await API.getLocation();
// Ortsmitte via Nominatim: erst Ort (zoom=10), dann Nominatim-Suche nach Ortsname
let lat = pos.lat, lon = pos.lon;
try {
const rev = await fetch(
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json&zoom=10&addressdetails=1&accept-language=de`,
{ cache: 'no-store' }
);
const d = await rev.json();
const a = d.address || {};
const ort = a.city || a.town || a.village || a.municipality || '';
if (ort) {
// Ortsname vorwärts-geocodieren um echte Ortsmitte zu bekommen
const fwd = await fetch(
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(ort)}&format=json&limit=1&accept-language=de`,
{ cache: 'no-store' }
);
const results = await fwd.json();
if (results[0]?.lat && results[0]?.lon) {
lat = parseFloat(results[0].lat);
lon = parseFloat(results[0].lon);
}
}
} catch {}
await API.forum.setLocation(lat, lon, true);
UI.toast.success('Ortsmitte geteilt — dein genauer Standort bleibt privat.');
_loadMembersOnMap();
} catch (err) {
e.target.checked = false;
UI.toast.error('Standort konnte nicht ermittelt werden.');
}
} else {
try {
await API.forum.setLocation(null, null, false);
UI.toast.success('Standort versteckt.');
_loadMembersOnMap();
} catch (err) { UI.toast.error(err.message); }
}
});
await _loadLeaflet();
const mapEl = document.getElementById('forum-map');
if (!mapEl) return;
_map = L.map(mapEl, { zoomControl: true }).setView([51.0, 10.0], 6);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap',
maxZoom: 18,
}).addTo(_map);
_loadMembersOnMap();
}
async function _loadMembersOnMap() {
if (!_map) return;
try {
// MarkerCluster laden falls nicht vorhanden
if (!window.L.markerClusterGroup) {
await Promise.all([
new Promise((res, rej) => {
if (document.querySelector('link[href*="MarkerCluster"]')) { res(); return; }
const l1 = document.createElement('link'); l1.rel='stylesheet'; l1.href='/css/MarkerCluster.css'; l1.onload=res; l1.onerror=rej; document.head.appendChild(l1);
}),
new Promise((res, rej) => {
const s = document.createElement('script'); s.src='/js/leaflet.markercluster.js'; s.onload=res; s.onerror=rej; document.head.appendChild(s);
}),
]);
}
const members = await API.forum.membersMap();
// Alte Cluster-Gruppe sauber entfernen
if (_clusterGroup) { _map.removeLayer(_clusterGroup); _clusterGroup = null; }
_clusterGroup = L.markerClusterGroup({ maxClusterRadius: 60 });
members.forEach(m => {
const icon = L.divIcon({
className: '',
html: `<div style="width:32px;height:32px;border-radius:50%;
background:var(--c-primary);color:#fff;font-size:13px;font-weight:700;
display:flex;align-items:center;justify-content:center;
box-shadow:0 2px 5px rgba(0,0,0,0.35);
border:2px solid rgba(255,255,255,0.8)">${_esc((m.vorname||'?')[0].toUpperCase())}</div>`,
iconSize: [32, 32], iconAnchor: [16, 16],
});
_clusterGroup.addLayer(
L.marker([m.lat, m.lon], { icon })
.bindPopup(`<strong>${_esc(m.vorname || '?')}</strong>`)
);
});
_map.addLayer(_clusterGroup);
} catch (err) {
console.error('Mitgliederkarte Fehler:', err);
}
}
async function _loadLeaflet() {
if (_leafletLoaded || window.L) { _leafletLoaded = true; return; }
// CSS
if (!document.querySelector('link[href*="leaflet.css"]')) {
const lCss = document.createElement('link');
lCss.rel = 'stylesheet';
lCss.href = '/css/leaflet.css';
document.head.appendChild(lCss);
}
// JS
await new Promise((resolve, reject) => {
if (window.L) { resolve(); return; }
const s = document.createElement('script');
s.src = '/js/leaflet.js';
s.onload = resolve;
s.onerror = reject;
document.head.appendChild(s);
});
_leafletLoaded = true;
}
// ----------------------------------------------------------
// Moderations-Panel
// ----------------------------------------------------------
async function _showModPanel() {
let reports;
try {
reports = await API.forum.reports();
} catch (err) {
UI.toast.error(err.message);
return;
}
const body = reports.length
? `<div class="forum-mod-reports">
${reports.map(r => `
<div class="forum-mod-report-item" data-id="${r.id}">
<div style="font-size:var(--text-sm)">
<strong>${_esc(r.target_type)} #${r.target_id}</strong>
${_esc(r.grund)}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">
von ${_esc(r.melder_name || '?')} · ${_fmtDate(r.created_at)}
</div>
<button class="btn btn-sm btn-secondary forum-resolve-btn" data-id="${r.id}" style="margin-top:var(--space-2)">
${UI.icon('check')} Erledigt
</button>
</div>`).join('')}
</div>`
: `<p style="color:var(--c-text-muted);text-align:center;padding:var(--space-6)">Keine offenen Berichte.</p>`;
const footer = `<button type="button" class="btn btn-primary flex-1" id="mod-close">Schließen</button>`;
UI.modal.open({ title: `${UI.icon('scales')} Moderationsberichte`, body, footer });
document.getElementById('mod-close')?.addEventListener('click', UI.modal.close);
document.querySelectorAll('.forum-resolve-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const id = parseInt(btn.dataset.id);
try {
await API.forum.resolveReport(id);
btn.closest('.forum-mod-report-item')?.remove();
UI.toast.success('Erledigt.');
} catch (err) { UI.toast.error(err.message); }
});
});
}
// ----------------------------------------------------------
// Post bearbeiten (Besitzer)
// ----------------------------------------------------------
function _showEditPostModal(postId, currentText, container, threadId, uid, isMod) {
const id = 'forum-edit-post-form';
UI.modal.open({
title: 'Antwort bearbeiten',
body: `<form id="${id}">
<div class="form-group">
<textarea class="form-control" name="text" rows="5" required>${_esc(currentText)}</textarea>
</div>
</form>`,
footer: `
<button class="btn btn-ghost flex-1" onclick="UI.modal.close()">Abbrechen</button>
<button type="submit" form="${id}" class="btn btn-primary flex-1">${UI.icon('floppy-disk')} Speichern</button>`,
});
document.getElementById(id)?.addEventListener('submit', async e => {
e.preventDefault();
const text = new FormData(e.target).get('text')?.trim();
if (!text) return;
const btn = document.querySelector(`[form="${id}"][type="submit"]`);
await UI.asyncButton(btn, async () => {
await API.forum.updatePost(postId, { text });
UI.modal.close();
// Post-Text im DOM aktualisieren
if (container) {
const postEl = container.querySelector(`[data-post-id="${postId}"]`);
const textEl = postEl?.querySelector('.forum-post-text');
if (textEl) textEl.textContent = text;
// data-text auf Edit-Button aktualisieren
const editBtn = postEl?.querySelector('.forum-post-edit');
if (editBtn) editBtn.dataset.text = text;
}
UI.toast.success('Antwort aktualisiert.');
});
});
}
// ----------------------------------------------------------
// Thread bearbeiten (Besitzer)
// ----------------------------------------------------------
function _showEditThreadModal(thread) {
const id = 'forum-edit-thread-form';
UI.modal.open({
title: 'Beitrag bearbeiten',
body: `<form id="${id}">
<div class="form-group">
<label class="form-label">Titel</label>
<input class="form-control" name="titel" value="${_esc(thread.titel || '')}" required>
</div>
<div class="form-group">
<label class="form-label">Text</label>
<textarea class="form-control" name="text" rows="5">${_esc(thread.text || '')}</textarea>
</div>
<div class="form-group">
<label class="form-label">Standort <span style="color:var(--c-text-secondary)">(optional)</span></label>
<div id="forum-edit-location-picker"></div>
</div>
</form>`,
footer: `
<button class="btn btn-ghost flex-1" onclick="UI.modal.close()">Abbrechen</button>
<button type="submit" form="${id}" class="btn btn-primary flex-1">${UI.icon('floppy-disk')} Speichern</button>`,
});
let _picker = null;
setTimeout(() => {
_picker = UI.locationPicker({ containerId: 'forum-edit-location-picker' });
if (thread.thread_lat && thread.thread_lon) {
_picker.setValue(thread.thread_lat, thread.thread_lon, thread.thread_ort || null);
}
}, 50);
document.getElementById(id)?.addEventListener('submit', async e => {
e.preventDefault();
const fd = new FormData(e.target);
const loc = _picker ? _picker.getValue() : { lat: null, lon: null, name: null };
const btn = document.querySelector(`[form="${id}"][type="submit"]`);
await UI.asyncButton(btn, async () => {
await API.forum.updateThread(thread.id, {
titel: fd.get('titel'),
text: fd.get('text'),
thread_lat: loc.lat ?? null,
thread_lon: loc.lon ?? null,
thread_ort: loc.name ?? null,
});
UI.modal.close();
_loadThreads(true);
UI.toast.success('Beitrag aktualisiert.');
});
});
}
function openNew() {
if (!_appState?.user) { UI.toast.info('Bitte erst anmelden.'); return; }
_showCreateForm();
}
function _showLightbox(src) {
const lb = document.createElement('div');
lb.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:9999;display:flex;align-items:center;justify-content:center;cursor:zoom-out';
lb.innerHTML = `<img src="${UI.escape(src)}" style="max-width:100%;max-height:100%;object-fit:contain;touch-action:pinch-zoom">
<button style="position:absolute;top:16px;right:16px;background:rgba(255,255,255,.2);border:none;border-radius:50%;width:40px;height:40px;color:#fff;font-size:22px;cursor:pointer;display:flex;align-items:center;justify-content:center">✕</button>`;
lb.addEventListener('click', () => lb.remove());
document.body.appendChild(lb);
}
return { init, refresh, onDogChange, openNew, openThread: _openThread };
})();