/* ============================================================
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,'&').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();
_loadHdmCard();
_loadThreads(true);
}
function refresh() {
_loadThreads(true);
}
function onDogChange() {}
// ----------------------------------------------------------
// RENDER — Grundstruktur
// ----------------------------------------------------------
function _render() {
const isMod = !!_appState.user?.is_moderator;
_container.innerHTML = `
${KATEGORIEN.map(k => `
${_esc(k.label)}
`).join('')}
${UI.icon('users')} Mitgliederkarte
`;
// 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));
// 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
? `🥇 ${_esc(top.name)}${top.rasse ? ` · ${_esc(top.rasse)}` : ''}`
: 'Noch keine Stimmen';
const metaLine = top
? `${top.stimmen} Stimme${top.stimmen !== 1 ? 'n' : ''}`
: 'Sei der Erste!';
card.innerHTML = `
🏆
Hund des Monats · ${_esc(monthName)}
${winnerLine}
${metaLine}
${UI.icon('arrow-right')}
`;
document.getElementById('forum-hdm-tile')?.addEventListener('click', () => _openHdmModal(data));
} catch {
// Kachel bleibt leer bei Fehler
}
}
async function _openHdmModal(data) {
// Immer frische Daten laden
try { data = await API.get('/movies/hund-des-monats'); } catch { /* nutze gecachte */ }
const [year, month] = data.monat.split('-');
const monthName = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' })
.format(new Date(+year, +month - 1, 1));
let voteSection = '';
if (_appState.user && _appState.dogs?.length > 0) {
const cards = _appState.dogs.map(dog => {
const isVoted = data.user_vote === dog.id;
const av = dog.foto_url
? ` `
: `${_esc(dog.name.charAt(0).toUpperCase())} `;
return `
${av}
${_esc(dog.name)}
${dog.rasse ? `
${_esc(dog.rasse)}
` : ''}
${isVoted ? `${UI.icon('check-circle')} Gewählt` : 'Abstimmen'}
`;
}).join('');
voteSection = `
Für welchen deiner Hunde möchtest du abstimmen?
${cards}
`;
} else if (!_appState.user) {
voteSection = `
Anmelden
um für deinen Hund abzustimmen.
`;
}
const topList = data.top?.length
? data.top.slice(0, 5).map((dog, i) => {
const medal = ['🥇','🥈','🥉','4️⃣','5️⃣'][i];
const av = dog.foto_url
? ` `
: `${_esc(dog.name.charAt(0).toUpperCase())} `;
const vorname = dog.besitzer_name ? _esc(dog.besitzer_name.split(' ')[0]) : '';
return `
${medal}
${av}
${_esc(dog.name)}
${dog.rasse ? `
${_esc(dog.rasse)}
` : ''}
${vorname ? `
von ${vorname}
` : ''}
${dog.stimmen} ${UI.icon('star')}
`;
}).join('')
: `Noch keine Stimmen diesen Monat. Sei der Erste!
`;
const body = `
${voteSection}
Top 5 diesen Monat
${topList}
`;
UI.modal.open({ title: '🏆 Hund des Monats', body,
footer: `Schließen ` });
document.getElementById('hdm-login-link')?.addEventListener('click', e => {
e.preventDefault(); UI.modal.close(); App.navigate('settings');
});
document.querySelectorAll('.hdm-vote-btn').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 });
UI.toast.success('Stimme abgegeben!');
UI.modal.close();
_loadHdmCard();
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Abstimmen.');
}
});
});
});
}
// ----------------------------------------------------------
// 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.
Ersten Beitrag erstellen
`;
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 ? `
Mehr laden
` : ''}
`;
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
? /\.(mp4|mov|webm|m4v|avi)$/i.test(t.foto_preview)
? `${UI.icon('video-camera')}
`
: ` `
: '';
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) ? `
${UI.icon('push-pin')} ${thread.is_pinned ? 'Unpin' : 'Pin'}
${UI.icon('lock')} ${thread.is_locked ? 'Entsperren' : 'Sperren'}
${UI.icon('trash')} Thread
` : '';
const _forumMediaHtml = (u) => {
if (u.endsWith('.pdf'))
return `
${UI.icon('file-text')} ${_esc(u.split('/').pop())} `;
if (/\.(mp4|mov|webm|m4v|avi)$/i.test(u)) {
const poster = u.replace(/\.[^.]+$/, '_thumb.jpg');
return ` `;
}
return ` `;
};
const fotoGallery = (thread.foto_urls?.length)
? `${thread.foto_urls.map(_forumMediaHtml).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.text)}
${fotoGallery}
${_esc(_initial(thread.autor_name))}
${_esc(thread.autor_name || 'Unbekannt')}
${thread.autor_founder_number ? `
Gründer #${thread.autor_founder_number} ` : ''}
${UI.icon('heart')} ${thread.likes || 0}
${_appState.user && !isOwn ? `${UI.icon('flag')} ` : ''}
${thread.antworten || 0} Antworten
${postsHtml}
${replySection}
`;
const footer = _appState.user ? `
${(isOwn || isMod) ? `
${UI.icon('trash')} ` : ''}
${isOwn ? `
${UI.icon('pencil-simple')} ` : ''}
Schließen
${(!thread.is_locked && _appState.user) ? `Antworten ` : ''}
` : `Schließen `;
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, 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 `
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(p.text)}
${fotoHtml}
${UI.icon('heart')} ${p.likes || 0}
${(!isOwn && uid) ? `
${UI.icon('flag')} ` : ''}
${isOwn ? `${UI.icon('pencil-simple')} ` : ''}
${canDelete ? `${UI.icon('trash')} ` : ''}
`;
}
// ----------------------------------------------------------
// 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 = '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 = `
Abbrechen
Melden `;
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('check')} Freundlich und respektvoll bleiben — auch bei verschiedenen Meinungen
${UI.icon('check')} Konstruktive Kritik statt persönliche Angriffe
${UI.icon('check')} Andere Haltungsmethoden tolerieren — solange der Hund nicht leidet
${UI.icon('prohibit')} Keine Beleidigungen, Drohungen oder Diskriminierung
${UI.icon('files')} Inhalte
${UI.icon('check')} Thema passend zur gewählten Kategorie
${UI.icon('check')} Aussagekräftiger Titel — kein "Hilfe!!!" ohne Kontext
${UI.icon('prohibit')} Keine Werbung, Spam oder Affiliate-Links
${UI.icon('prohibit')} Keine Doppelposts — bestehenden Thread suchen bevor du neu erstellst
${UI.icon('syringe')} Gesundheit & Notfälle
${UI.icon('check')} Erfahrungen teilen ist wertvoll — bitte immer den Tierarzt empfehlen
${UI.icon('prohibit')} Keine Diagnosen oder Medikamentendosierungen für fremde Hunde
${UI.icon('warning-circle')} Bei Notfällen: direkt zum Tierarzt — nicht erst im Forum fragen
${UI.icon('flag')} Moderation
${UI.icon('check')} Regelverstoß? Melde-Funktion nutzen statt selbst zu reagieren
${UI.icon('check')} Moderatoren können Beiträge bearbeiten, verstecken oder löschen
${UI.icon('check')} Bei Unklarheiten freundlich nachfragen
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: `Verstanden `,
});
}
// ----------------------------------------------------------
function _showCreateForm() {
const katOptions = KATEGORIEN.filter(k => k.key !== 'alle').map(k =>
`${_esc(k.label)} `
).join('');
const body = `
${UI.icon('info')} Bitte die
Regeln & Netiquette
beachten.
`;
const footer = `
Abbrechen
${UI.icon('chat-circle-dots')} Erstellen `;
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 = `
`;
// 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: `${_esc((m.vorname||'?')[0].toUpperCase())}
`,
iconSize: [32, 32], iconAnchor: [16, 16],
});
_clusterGroup.addLayer(
L.marker([m.lat, m.lon], { icon })
.bindPopup(`${_esc(m.vorname || '?')} `)
);
});
_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
? `
${reports.map(r => `
${_esc(r.target_type)} #${r.target_id}
— ${_esc(r.grund)}
von ${_esc(r.melder_name || '?')} · ${_fmtDate(r.created_at)}
${UI.icon('check')} Erledigt
`).join('')}
`
: `Keine offenen Berichte.
`;
const footer = `Schließen `;
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: `
${_esc(currentText)}
`,
footer: `
Abbrechen
${UI.icon('floppy-disk')} Speichern `,
});
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: `
Titel
Text
${_esc(thread.text || '')}
`,
footer: `
Abbrechen
${UI.icon('floppy-disk')} Speichern `,
});
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 = `
✕ `;
lb.addEventListener('click', () => lb.remove());
document.body.appendChild(lb);
}
return { init, refresh, onDogChange, openNew, openThread: _openThread };
})();