banyaro/backend/static/js/pages/forum.js

1474 lines
63 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 _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, params = {}) {
_container = container;
_appState = appState;
_render();
_loadHdmCard();
_loadThreads(true);
// Rassen-Suche vorausfüllen (Feature 3: Same-Breed-Chip)
if (params.search) {
const searchInput = document.getElementById('forum-search');
if (searchInput) {
searchInput.value = params.search;
searchInput.dispatchEvent(new Event('input', { bubbles: 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" class="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}"><span class="by-tab-text">${UI.escape(k.label)}</span></button>
`).join('')}
<button class="by-tab ${_activeSection === 'map' ? 'active' : ''}"
data-section="map"><span class="by-tab-text">${UI.icon('users')} Mitgliederkarte</span></button>
</div>
<!-- Rechte Spalte: HdM-Kachel + Suche + Threads -->
<div class="forum-main-col">
<div id="forum-hdm-card"></div>
<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>
</div>
`;
// Desktop: Tabs gleichmäßig auf 2 Zeilen verteilen
const _tabsEl = document.getElementById('forum-tabs');
const _tabCount = _tabsEl.querySelectorAll('.by-tab').length;
_tabsEl.style.setProperty('--forum-tab-cols', Math.ceil(_tabCount / 2));
// Marquee-Scroll: nur Tabs animieren, bei denen Text wirklich abgeschnitten ist
_tabsEl.addEventListener('mouseenter', e => {
const btn = e.target.closest('.by-tab');
const span = btn?.querySelector('.by-tab-text');
if (!span) return;
const style = getComputedStyle(btn);
const padH = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
const overflow = span.scrollWidth - (btn.clientWidth - padH);
if (overflow <= 2) return;
span.style.setProperty('--tab-scroll-px', `-${overflow}px`);
span.classList.add('scrolling');
}, true);
_tabsEl.addEventListener('mouseleave', e => {
const span = e.target.closest('.by-tab')?.querySelector('.by-tab-text');
if (span) span.classList.remove('scrolling');
}, true);
// Tab-Klicks
_tabsEl.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);
}
// ----------------------------------------------------------
// Hund des Monats — Kachel + Modal
// ----------------------------------------------------------
async function _loadHdmCard() {
const card = document.getElementById('forum-hdm-card');
if (!card) return;
try {
const data = await API.get('/movies/hund-des-monats');
const [year, month] = data.monat.split('-');
const monthName = new Intl.DateTimeFormat('de-DE', { month: 'long' })
.format(new Date(+year, +month - 1, 1));
const top = data.top?.[0];
const winnerLine = top
? `🥇 ${UI.escape(top.name)}${top.rasse ? ` · ${UI.escape(top.rasse)}` : ''}`
: 'Noch keine Stimmen';
const metaLine = top
? `${top.stimmen} Stimme${top.stimmen !== 1 ? 'n' : ''}`
: 'Sei der Erste!';
card.innerHTML = `
<div class="forum-hdm-tile" id="forum-hdm-tile">
<div class="forum-hdm-tile-trophy">🏆</div>
<div class="forum-hdm-tile-body">
<div class="forum-hdm-tile-title">Hund des Monats · ${UI.escape(monthName)}</div>
<div class="forum-hdm-tile-winner">${winnerLine}</div>
<div class="forum-hdm-tile-meta">${metaLine}</div>
</div>
<div class="forum-hdm-tile-cta">${UI.icon('arrow-right')}</div>
</div>`;
document.getElementById('forum-hdm-tile')?.addEventListener('click', () => _openHdmModal(data));
} catch {
// Kachel bleibt leer bei Fehler
}
}
async function _openHdmModal(data) {
try { data = await API.get('/movies/hund-des-monats'); } catch { /* gecachte Daten */ }
const [year, month] = data.monat.split('-');
const monthName = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' })
.format(new Date(+year, +month - 1, 1));
const topList = data.top?.length
? data.top.slice(0, 5).map((dog, i) => {
const medal = ['🥇','🥈','🥉','4⃣','5⃣'][i];
const av = dog.foto_url
? `<img src="${UI.escape(dog.foto_url)}" alt="${UI.escape(dog.name)}" class="hdm-top-av-img">`
: `<span class="hdm-top-av-placeholder">${UI.escape(dog.name.charAt(0).toUpperCase())}</span>`;
const vorname = dog.besitzer_name ? UI.escape(dog.besitzer_name.split(' ')[0]) : '';
return `
<div class="hdm-top-entry">
<span class="hdm-top-medal">${medal}</span>
<div class="hdm-top-av">${av}</div>
<div class="hdm-top-info">
<div class="hdm-top-name">${UI.escape(dog.name)}</div>
${dog.rasse ? `<div class="hdm-top-rasse">${UI.escape(dog.rasse)}</div>` : ''}
${vorname ? `<div class="hdm-top-besitzer">von ${vorname}</div>` : ''}
</div>
<div class="hdm-top-stimmen">${dog.stimmen} ${UI.icon('star')}</div>
</div>`;
}).join('')
: `<p style="color:var(--c-text-secondary);padding:var(--space-4)">Noch keine Stimmen. Sei der Erste!</p>`;
const voteHint = !_appState.user
? `<div class="hdm-section">
<p style="color:var(--c-text-secondary);font-size:var(--text-sm)">
<a href="#" id="hdm-login-link" style="color:var(--c-primary);font-weight:var(--weight-semibold)">Anmelden</a>
um abstimmen zu können.
</p>
</div>`
: `<div class="hdm-section">
<h3 class="hdm-section-title">Für welchen Hund möchtest du abstimmen?</h3>
<div class="hdm-kandidaten-search">
<input type="search" id="hdm-search" class="form-control"
placeholder="Name oder Rasse suchen …" autocomplete="off"
class="text-sm">
</div>
<div id="hdm-kandidaten-grid" class="hdm-vote-grid">
${UI.skeleton(3)}
</div>
</div>`;
const body = `
<div class="hdm-header">
<div class="hdm-trophy">🏆</div>
<h2 class="hdm-title">Hund des Monats</h2>
<div class="hdm-monat">${UI.escape(monthName)}</div>
</div>
${voteHint}
<div class="hdm-section">
<h3 class="hdm-section-title">Top 5 diesen Monat</h3>
<div id="hdm-top-list">${topList}</div>
</div>`;
UI.modal.open({ title: '🏆 Hund des Monats', body,
footer: `<button class="btn btn-secondary flex-1" onclick="UI.modal.close()">Schließen</button>` });
document.getElementById('hdm-login-link')?.addEventListener('click', e => {
e.preventDefault(); UI.modal.close(); App.navigate('settings');
});
if (!_appState.user) return;
// Kandidaten laden und rendern
let _kandidaten = [];
const _renderKandidaten = (list) => {
const grid = document.getElementById('hdm-kandidaten-grid');
if (!grid) return;
if (!list.length) {
grid.innerHTML = `<p style="color:var(--c-text-secondary);font-size:var(--text-sm);padding:var(--space-3) 0">Keine Hunde gefunden.</p>`;
return;
}
grid.innerHTML = list.map(dog => {
const isVoted = data.user_vote === dog.id;
const av = dog.foto_url
? `<img src="${UI.escape(dog.foto_url)}" alt="${UI.escape(dog.name)}" class="hdm-vote-av-img">`
: `<span class="hdm-vote-av-placeholder">${UI.escape(dog.name.charAt(0).toUpperCase())}</span>`;
const vorname = dog.besitzer_name ? UI.escape(dog.besitzer_name.split(' ')[0]) : '';
return `
<div class="hdm-vote-card${isVoted ? ' hdm-vote-card--voted' : ''}">
<div class="hdm-vote-av">${av}</div>
<div class="hdm-vote-name">${UI.escape(dog.name)}</div>
${dog.rasse ? `<div class="hdm-vote-rasse">${UI.escape(dog.rasse)}</div>` : ''}
${vorname ? `<div class="hdm-vote-besitzer text-xs-muted">von ${vorname}</div>` : ''}
${dog.stimmen > 0 ? `<div class="text-xs-muted">${dog.stimmen} ${UI.icon('star')}</div>` : ''}
<button class="btn btn-sm ${isVoted ? 'btn-primary' : 'btn-secondary'} hdm-vote-btn"
data-dog-id="${dog.id}" ${isVoted ? 'disabled' : ''}>
${isVoted ? `${UI.icon('check-circle')} Gewählt` : 'Abstimmen'}
</button>
</div>`;
}).join('');
grid.querySelectorAll('.hdm-vote-btn:not([disabled])').forEach(btn => {
btn.addEventListener('click', async () => {
const dogId = parseInt(btn.dataset.dogId);
await UI.asyncButton(btn, async () => {
try {
await API.post('/movies/hund-des-monats/vote', { dog_id: dogId });
data.user_vote = dogId;
UI.toast.success('Stimme abgegeben!');
UI.modal.close();
_loadHdmCard();
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Abstimmen.');
}
});
});
});
};
try {
_kandidaten = await API.get('/movies/hund-des-monats/kandidaten');
} catch {
document.getElementById('hdm-kandidaten-grid').innerHTML =
`<p style="color:var(--c-danger);font-size:var(--text-sm)">Kandidaten konnten nicht geladen werden.</p>`;
return;
}
_renderKandidaten(_kandidaten);
document.getElementById('hdm-search')?.addEventListener('input', e => {
const q = e.target.value.trim().toLowerCase();
_renderKandidaten(q
? _kandidaten.filter(d =>
(d.name || '').toLowerCase().includes(q) ||
(d.rasse || '').toLowerCase().includes(q))
: _kandidaten
);
});
}
// ----------------------------------------------------------
// 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 class="text-secondary">Noch keine Beiträge in dieser Kategorie.</p>
<button class="btn btn-primary mt-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
? UI.escape(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="${UI.escape(t.foto_preview_url || t.foto_preview)}"
${(t.foto_preview_url && t.foto_preview) ? `srcset="${UI.escape(t.foto_preview_url)} 800w" sizes="120px"` : ''}
alt="" loading="lazy"
onerror="this.src='${UI.escape(t.foto_preview)}'">`
: '';
return `
<div class="forum-thread-card" data-id="${t.id}">
<div class="forum-card-top">
<span class="forum-category-badge forum-category-badge--${UI.escape(t.kategorie)}">${UI.escape(t.kategorie)}</span>
${pinBadge}${lockBadge}
</div>
<div class="forum-card-content">
<div class="forum-card-main">
<div class="forum-card-title">${UI.escape(t.titel)}</div>
${preview ? `<div class="forum-card-preview">${preview}</div>` : ''}
<div class="forum-card-meta">
<span>${UI.icon('user')} ${UI.escape(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 class="text-secondary">Keine Ergebnisse für „${UI.escape(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 text-danger">${UI.icon('trash')} Thread</button>
</div>` : '';
const _forumMediaHtml = (u) => {
if (u.endsWith('.pdf'))
return `<a href="${UI.escape(u)}" target="_blank" rel="noopener" class="forum-pdf-card">
${UI.icon('file-text')} <span>${UI.escape(u.split('/').pop())}</span></a>`;
if (/\.(mp4|mov|webm|m4v|avi)$/i.test(u)) {
const poster = u.replace(/\.[^.]+$/, '_thumb.jpg');
return `<video src="${UI.escape(u)}" poster="${UI.escape(poster)}" controls playsinline
style="max-width:100%;max-height:320px;border-radius:var(--radius-md);display:block"></video>`;
}
return `<img src="${UI.escape(u)}" class="forum-foto-img" data-src="${UI.escape(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" class="hidden">
</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--${UI.escape(thread.kategorie)}">${UI.escape(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">${UI.escape(thread.text)}</p>
${fotoGallery}
</div>
<div class="forum-thread-author-row">
<div class="forum-avatar">${UI.escape(_initial(thread.autor_name))}</div>
<span style="font-size:0.85rem;color:var(--c-text-secondary)">${UI.escape(thread.autor_name || 'Unbekannt')}</span>
${thread.autor_founder_number ? `<span style="font-size:10px;font-weight:700;background:#7c3aed;color:#fff;padding:1px 5px;border-radius:4px">Gründer #${thread.autor_founder_number}</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')} ${UI.escape(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); }
});
// Liker-Liste anzeigen (Klick auf die Zahl)
const _thLikeCount = document.getElementById('thread-like-count');
if (_thLikeCount) {
_thLikeCount.style.cursor = 'pointer';
_thLikeCount.title = 'Wer hat geliked?';
_thLikeCount.addEventListener('click', e => {
e.stopPropagation();
if ((thread.likes || 0) > 0) _showLikers('thread', thread.id);
});
}
// 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, client_time: API.clientNow() });
// 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="${UI.escape(u)}" class="forum-foto-img" data-src="${UI.escape(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">${UI.escape(_initial(p.autor_name))}</div>
<span class="forum-post-author">${UI.escape(p.autor_name || 'Unbekannt')}</span>
${p.autor_founder_number ? `<span style="font-size:10px;font-weight:700;background:#7c3aed;color:#fff;padding:1px 5px;border-radius:4px">Gründer #${p.autor_founder_number}</span>` : ''}
<span class="forum-post-date">${_fmtDate(p.created_at)}</span>
</div>
<div class="forum-post-body">
<div class="forum-post-text">${UI.escape(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="${UI.escape(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';
const postId = parseInt(btn.dataset.postId);
btn.addEventListener('click', async () => {
if (!uid) { UI.toast.info('Bitte erst anmelden.'); return; }
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); }
});
// Klick auf die Zahl → Liker-Liste
const countEl = btn.querySelector('.forum-post-like-count');
if (countEl) {
countEl.style.cursor = 'pointer';
countEl.title = 'Wer hat geliked?';
countEl.addEventListener('click', e => {
e.stopPropagation();
if (parseInt(countEl.textContent) > 0) _showLikers('post', postId);
});
}
});
// 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 class="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));
});
}
// ----------------------------------------------------------
// Liker-Liste — wer hat geliked?
// ----------------------------------------------------------
async function _showLikers(targetType, targetId) {
try {
const likers = await API.forum.likers(targetType, targetId);
if (!likers.length) { UI.toast.info('Noch keine Likes.'); return; }
const rows = likers.map(l => `
<div style="display:flex;align-items:center;gap:var(--space-2);padding:var(--space-2) 0;border-bottom:1px solid var(--c-border-light)">
<div class="forum-avatar forum-avatar--sm">${UI.escape(_initial(l.name))}</div>
<span style="font-size:0.9rem">${UI.escape(l.name || 'Unbekannt')}</span>
${l.founder_number ? `<span style="font-size:10px;font-weight:700;background:#7c3aed;color:#fff;padding:1px 5px;border-radius:4px;margin-left:auto">Gründer #${l.founder_number}</span>` : ''}
</div>`).join('');
UI.modal.open({
title: `${UI.icon('heart')} ${likers.length} ${likers.length === 1 ? 'Like' : 'Likes'}`,
body: `<div style="max-height:50vh;overflow-y:auto">${rows}</div>`,
footer: `<button type="button" class="btn btn-secondary w-full" id="likers-close">Schließen</button>`,
});
document.getElementById('likers-close')?.addEventListener('click', UI.modal.close);
} catch (err) { UI.toast.error(err.message); }
}
// ----------------------------------------------------------
// 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' : ''}>${UI.escape(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 class="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 class="hidden">
</div>
<div id="forum-thread-previews" class="forum-upload-previews mt-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,
client_time: API.clientNow(),
});
// 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)">${UI.escape((m.vorname||'?')[0].toUpperCase())}</div>`,
iconSize: [32, 32], iconAnchor: [16, 16],
});
_clusterGroup.addLayer(
L.marker([m.lat, m.lon], { icon })
.bindPopup(`<strong>${UI.escape(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 class="text-sm">
<strong>${UI.escape(r.target_type)} #${r.target_id}</strong>
${UI.escape(r.grund)}
</div>
<div class="text-xs-muted">
von ${UI.escape(r.melder_name || '?')} · ${_fmtDate(r.created_at)}
</div>
<button class="btn btn-sm btn-secondary forum-resolve-btn" data-id="${r.id}" class="mt-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>${UI.escape(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="${UI.escape(thread.titel || '')}" required>
</div>
<div class="form-group">
<label class="form-label">Text</label>
<textarea class="form-control" name="text" rows="5">${UI.escape(thread.text || '')}</textarea>
</div>
<div class="form-group">
<label class="form-label">Standort <span class="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:44px;height:44px;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 };
})();