/* ============================================================ 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 _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,'&').replace(//g,'>').replace(/"/g,'"'); } 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 = `

Forum

${isMod ? `` : ''}
${KATEGORIEN.map(k => ` `).join('')}

Lädt…

`; // 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 = `

Lädt…

`; } 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 = `
${UI.icon('chat-circle-dots')}

Noch keine Beiträge in dieser Kategorie.

`; document.getElementById('forum-first-btn')?.addEventListener('click', () => { if (!_appState.user) { UI.toast.info('Bitte erst anmelden.'); return; } _showCreateForm(); }); return; } el.innerHTML = `
${_threads.map(_threadCardHTML).join('')}
${!noMore ? `
` : ''} `; 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 ? `${UI.icon('push-pin')}` : ''; const lockBadge = t.is_locked ? `${UI.icon('lock')}` : ''; const fotoHtml = t.foto_preview ? `` : ''; return `
${_esc(t.kategorie)} ${pinBadge}${lockBadge}
${_esc(t.titel)}
${preview ? `
${preview}
` : ''}
${UI.icon('user')} ${_esc(t.autor_name || 'Unbekannt')} ${UI.icon('calendar-dots')} ${_fmtDate(t.created_at)} ${UI.icon('chat-circle-dots')} ${t.antworten || 0} ${UI.icon('heart')} ${t.likes || 0}
${fotoHtml}
`; } // ---------------------------------------------------------- // Suche // ---------------------------------------------------------- async function _doSearch(q) { _searching = true; const el = document.getElementById('forum-main'); if (el) el.innerHTML = `

Suche…

`; try { const results = await API.forum.search(q); if (!document.getElementById('forum-main')) return; if (!results.length) { document.getElementById('forum-main').innerHTML = `
${UI.icon('magnifying-glass')}

Keine Ergebnisse für „${_esc(q)}"

`; return; } document.getElementById('forum-main').innerHTML = `
${results.map(t => _threadCardHTML({ ...t, foto_preview: null, is_pinned: 0, is_locked: 0, user_liked: false })).join('')}
`; 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) ? `
` : ''; const fotoGallery = (thread.foto_urls?.length) ? `
${thread.foto_urls.map(u => `` ).join('')}
` : ''; 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('') : `

Noch keine Antworten.

`; const replySection = _appState.user && !thread.is_locked ? `
` : (!_appState.user ? `

Bitte anmelden um zu antworten.

` : `

${UI.icon('lock')} Dieser Thread ist gesperrt.

` ); const body = `
${modToolbar}
${_esc(thread.kategorie)} ${_fmtDate(thread.created_at)} ${thread.is_pinned ? `${UI.icon('push-pin')}` : ''} ${thread.is_locked ? `${UI.icon('lock')}` : ''}

${_esc(thread.text)}

${fotoGallery}
${_esc(_initial(thread.autor_name))}
${_esc(thread.autor_name || 'Unbekannt')}
${_appState.user && !isOwn ? `` : ''}
${thread.antworten || 0} Antworten
${postsHtml}
${replySection}
`; const footer = _appState.user ? ` ${(isOwn || isMod) ? `` : ''} ${(!thread.is_locked && _appState.user) ? `` : ''} ` : ``; 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); } }); // 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 `
Beitrag wurde entfernt
`; } const isOwn = uid && uid === p.user_id; const fotoHtml = (p.foto_urls?.length) ? `
${p.foto_urls.map(u => `` ).join('')}
` : ''; const likeClass = p.user_liked ? 'forum-like-btn active' : 'forum-like-btn'; const canDelete = isOwn || isMod; return `
${_esc(_initial(p.autor_name))}
${_esc(p.text)}
${fotoHtml}
${(!isOwn && uid) ? `` : ''} ${canDelete ? `` : ''}
`; } // ---------------------------------------------------------- // 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)); }); }); // 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 = 'Beitrag wurde entfernt'; 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 = `
`; const footer = ` `; 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: `

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.

${UI.icon('chat-circle-dots')} Ton & Umgang
${UI.icon('files')} Inhalte
${UI.icon('syringe')} Gesundheit & Notfälle
${UI.icon('flag')} Moderation

Wer wiederholt gegen die Regeln verstößt, kann vorübergehend gesperrt werden. Das Ziel ist ein freundliches Forum — nicht Kontrolle um der Kontrolle willen. 🐾

`, footer: ``, }); } // ---------------------------------------------------------- function _showCreateForm() { const katOptions = KATEGORIEN.filter(k => k.key !== 'alle').map(k => `` ).join(''); const body = `

${UI.icon('info')} Bitte die beachten.

`; const footer = ` `; 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 document.getElementById('forum-thread-files')?.addEventListener('change', e => { const previews = document.getElementById('forum-thread-previews'); if (!previews) return; previews.innerHTML = ''; Array.from(e.target.files || []).slice(0, 5).forEach(file => { const img = document.createElement('img'); img.src = URL.createObjectURL(file); img.className = 'forum-upload-thumb'; previews.appendChild(img); }); }); 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 const files = Array.from(document.getElementById('forum-thread-files')?.files || []); for (const file of files.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 = `
Mitglieder auf der Karte ${_appState.user ? ` ` : ''}
`; // Location-Toggle document.getElementById('forum-loc-toggle')?.addEventListener('change', async e => { const show = e.target.checked; if (show) { try { const pos = await API.getLocation(); await API.forum.setLocation(pos.lat, pos.lon, true); UI.toast.success('Standort geteilt.'); _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.'); } 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 { const members = await API.forum.membersMap(); members.forEach(m => { const icon = L.divIcon({ className: '', html: `
${_esc((m.vorname||'?')[0].toUpperCase())}
`, iconSize: [32, 32], iconAnchor: [16, 16], }); L.marker([m.lat, m.lon], { icon }) .bindPopup(`${_esc(m.vorname || '?')}`) .addTo(_map); }); } 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 ? `
${reports.map(r => `
${_esc(r.target_type)} #${r.target_id} — ${_esc(r.grund)}
von ${_esc(r.melder_name || '?')} · ${_fmtDate(r.created_at)}
`).join('')}
` : `

Keine offenen Berichte.

`; const footer = ``; 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); } }); }); } 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 = ` `; lb.addEventListener('click', () => lb.remove()); document.body.appendChild(lb); } return { init, refresh, onDogChange, openNew }; })();