- Freundschaften (pending/accepted), Nutzersuche, Anfragen per Push - Direktnachrichten mit Polling, iMessage-Stil, Deep-Links aus Push - Alle Seiten (map, places, diary, health, dog-profile, sitting, knigge, forum, wiki, walks) vollständig auf Phosphor-Icons migriert - Wikidata-Rassen-Scraper (~833 neue Rassen, lokal gespiegelte Fotos) - TheDogAPI lokal gespiegelt (169 Rassen + Fotos) - Quiz-Result-Cards horizontal (korrekte Bildproportionen) - SW by-v89
890 lines
35 KiB
JavaScript
890 lines
35 KiB
JavaScript
/* ============================================================
|
|
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: 'tauschboerse', label: 'Tauschbörse' },
|
|
];
|
|
|
|
// ----------------------------------------------------------
|
|
// Helpers
|
|
// ----------------------------------------------------------
|
|
function _esc(s) {
|
|
return String(s || '').replace(/&/g,'&').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 = `
|
|
<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-primary btn-sm" id="forum-new-btn">${UI.icon('plus')} Neues Thema</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Kategorie-Tabs -->
|
|
<div class="forum-category-tabs" id="forum-tabs">
|
|
${KATEGORIEN.map(k => `
|
|
<button class="forum-tab ${k.key === _aktivKat ? 'active' : ''}"
|
|
data-kat="${k.key}">${_esc(k.label)}</button>
|
|
`).join('')}
|
|
<button class="forum-tab ${_activeSection === 'map' ? 'active' : ''}"
|
|
data-section="map">${UI.icon('users')} Mitgliederkarte</button>
|
|
</div>
|
|
|
|
<!-- Suchleiste -->
|
|
<div class="forum-search-wrap">
|
|
<input type="search" class="forum-search" id="forum-search"
|
|
placeholder="Forum durchsuchen…" autocomplete="off">
|
|
</div>
|
|
|
|
<!-- Thread-Liste / Karte / Suche -->
|
|
<div id="forum-main">
|
|
<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Lädt…</p>
|
|
</div>
|
|
|
|
</div>
|
|
`;
|
|
|
|
// Tab-Klicks
|
|
document.getElementById('forum-tabs').addEventListener('click', e => {
|
|
const btn = e.target.closest('[data-kat], [data-section]');
|
|
if (!btn) return;
|
|
|
|
if (btn.dataset.section === 'map') {
|
|
_aktivKat = 'alle';
|
|
_activeSection = 'map';
|
|
document.querySelectorAll('.forum-tab').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
_renderMembersMap();
|
|
return;
|
|
}
|
|
|
|
_aktivKat = btn.dataset.kat;
|
|
_activeSection = 'list';
|
|
document.querySelectorAll('.forum-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);
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// Threads laden
|
|
// ----------------------------------------------------------
|
|
async function _loadThreads(reset = false) {
|
|
if (reset) { _offset = 0; _threads = []; }
|
|
|
|
const mainEl = document.getElementById('forum-main');
|
|
if (mainEl && reset) {
|
|
mainEl.innerHTML = `<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Lädt…</p>`;
|
|
}
|
|
|
|
try {
|
|
const params = { limit: LIMIT, offset: _offset };
|
|
if (_aktivKat !== 'alle') params.kategorie = _aktivKat;
|
|
|
|
const rows = await API.forum.threads(params);
|
|
_threads = reset ? rows : [..._threads, ...rows];
|
|
_offset += rows.length;
|
|
_renderList(rows.length < LIMIT);
|
|
} catch (err) {
|
|
UI.toast.error(err.message || 'Fehler beim Laden.');
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// Thread-Liste rendern
|
|
// ----------------------------------------------------------
|
|
function _renderList(noMore = false) {
|
|
if (_searching) return;
|
|
const el = document.getElementById('forum-main');
|
|
if (!el) return;
|
|
|
|
if (!_threads.length) {
|
|
el.innerHTML = `
|
|
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
|
|
<div style="font-size:3rem;margin-bottom:var(--space-3)">${UI.icon('chat-circle-dots')}</div>
|
|
<p style="color:var(--c-text-secondary)">Noch keine Beiträge in dieser Kategorie.</p>
|
|
<button class="btn btn-primary" style="margin-top:var(--space-4)" id="forum-first-btn">
|
|
Ersten Beitrag erstellen
|
|
</button>
|
|
</div>`;
|
|
document.getElementById('forum-first-btn')?.addEventListener('click', () => {
|
|
if (!_appState.user) { UI.toast.info('Bitte erst anmelden.'); return; }
|
|
_showCreateForm();
|
|
});
|
|
return;
|
|
}
|
|
|
|
el.innerHTML = `
|
|
<div class="forum-list-inner" id="forum-thread-list">
|
|
${_threads.map(_threadCardHTML).join('')}
|
|
</div>
|
|
${!noMore ? `<div style="text-align:center;padding:var(--space-4)">
|
|
<button class="btn btn-secondary btn-sm" id="forum-loadmore">Mehr laden</button>
|
|
</div>` : ''}
|
|
`;
|
|
|
|
el.querySelectorAll('.forum-thread-card').forEach(card => {
|
|
card.addEventListener('click', () => _openThread(parseInt(card.dataset.id)));
|
|
});
|
|
|
|
document.getElementById('forum-loadmore')?.addEventListener('click', () => {
|
|
_loadThreads(false);
|
|
});
|
|
}
|
|
|
|
function _threadCardHTML(t) {
|
|
const preview = t.text_preview
|
|
? _esc(t.text_preview.slice(0, 120)) + (t.text_preview.length >= 120 ? '…' : '')
|
|
: '';
|
|
const pinBadge = t.is_pinned ? `<span class="forum-pin-badge" title="Angepinnt">${UI.icon('push-pin')}</span>` : '';
|
|
const lockBadge = t.is_locked ? `<span class="forum-lock-badge" title="Gesperrt">${UI.icon('lock')}</span>` : '';
|
|
const fotoHtml = t.foto_preview
|
|
? `<img class="forum-card-thumb" src="${_esc(t.foto_preview)}" alt="" loading="lazy">`
|
|
: '';
|
|
|
|
return `
|
|
<div class="forum-thread-card" data-id="${t.id}">
|
|
<div class="forum-card-top">
|
|
<span class="forum-category-badge forum-category-badge--${_esc(t.kategorie)}">${_esc(t.kategorie)}</span>
|
|
${pinBadge}${lockBadge}
|
|
</div>
|
|
<div class="forum-card-content">
|
|
<div class="forum-card-main">
|
|
<div class="forum-card-title">${_esc(t.titel)}</div>
|
|
${preview ? `<div class="forum-card-preview">${preview}</div>` : ''}
|
|
<div class="forum-card-meta">
|
|
<span>${UI.icon('user')} ${_esc(t.autor_name || 'Unbekannt')}</span>
|
|
<span>${UI.icon('calendar-dots')} ${_fmtDate(t.created_at)}</span>
|
|
<span>${UI.icon('chat-circle-dots')} ${t.antworten || 0}</span>
|
|
<span class="${t.user_liked ? 'forum-liked' : ''}">${UI.icon('heart')} ${t.likes || 0}</span>
|
|
</div>
|
|
</div>
|
|
${fotoHtml}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// Suche
|
|
// ----------------------------------------------------------
|
|
async function _doSearch(q) {
|
|
_searching = true;
|
|
const el = document.getElementById('forum-main');
|
|
if (el) el.innerHTML = `<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-6)">Suche…</p>`;
|
|
|
|
try {
|
|
const results = await API.forum.search(q);
|
|
if (!document.getElementById('forum-main')) return;
|
|
if (!results.length) {
|
|
document.getElementById('forum-main').innerHTML = `
|
|
<div style="text-align:center;padding:var(--space-8)">
|
|
<div style="font-size:2rem;margin-bottom:var(--space-2)">${UI.icon('magnifying-glass')}</div>
|
|
<p style="color:var(--c-text-secondary)">Keine Ergebnisse für „${_esc(q)}"</p>
|
|
</div>`;
|
|
return;
|
|
}
|
|
document.getElementById('forum-main').innerHTML = `
|
|
<div class="forum-list-inner">
|
|
${results.map(t => _threadCardHTML({ ...t, foto_preview: null, is_pinned: 0, is_locked: 0, user_liked: false })).join('')}
|
|
</div>`;
|
|
document.getElementById('forum-main').querySelectorAll('.forum-thread-card').forEach(card => {
|
|
card.addEventListener('click', () => _openThread(parseInt(card.dataset.id)));
|
|
});
|
|
} catch (err) {
|
|
UI.toast.error(err.message || 'Suchfehler.');
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// Thread-Detail-Modal
|
|
// ----------------------------------------------------------
|
|
async function _openThread(threadId) {
|
|
let thread;
|
|
try {
|
|
thread = await API.forum.thread(threadId);
|
|
} catch (err) {
|
|
UI.toast.error(err.message);
|
|
return;
|
|
}
|
|
|
|
const uid = _appState.user?.id;
|
|
const isMod = !!_appState.user?.is_moderator;
|
|
const isOwn = uid && uid === thread.user_id;
|
|
|
|
const modToolbar = (isMod) ? `
|
|
<div class="forum-mod-toolbar">
|
|
<button class="btn btn-ghost btn-sm forum-mod-pin" title="${thread.is_pinned ? 'Unpin' : 'Anpinnen'}">
|
|
${UI.icon('push-pin')} ${thread.is_pinned ? 'Unpin' : 'Pin'}
|
|
</button>
|
|
<button class="btn btn-ghost btn-sm forum-mod-lock" title="${thread.is_locked ? 'Entsperren' : 'Sperren'}">
|
|
${UI.icon('lock')} ${thread.is_locked ? 'Entsperren' : 'Sperren'}
|
|
</button>
|
|
<button class="btn btn-ghost btn-sm forum-mod-delete-thread" style="color:var(--c-danger)">${UI.icon('trash')} Thread</button>
|
|
</div>` : '';
|
|
|
|
const fotoGallery = (thread.foto_urls?.length)
|
|
? `<div class="forum-foto-grid">${thread.foto_urls.map(u =>
|
|
`<img src="${_esc(u)}" class="forum-foto-img" data-src="${_esc(u)}" alt="" loading="lazy">`
|
|
).join('')}</div>`
|
|
: '';
|
|
|
|
const likeClass = thread.user_liked ? 'forum-like-btn active' : 'forum-like-btn';
|
|
|
|
const postsHtml = (thread.posts?.length)
|
|
? thread.posts.map(p => _postHTML(p, uid, isMod)).join('')
|
|
: `<p style="color:var(--c-text-muted);font-style:italic;padding:var(--space-3) 0">Noch keine Antworten.</p>`;
|
|
|
|
const replySection = _appState.user && !thread.is_locked ? `
|
|
<div class="forum-reply-form">
|
|
<label class="form-label">Antwort schreiben</label>
|
|
<textarea class="form-control" id="forum-reply-text" rows="3"
|
|
placeholder="Deine Antwort…"></textarea>
|
|
<div class="forum-reply-actions">
|
|
<label class="btn btn-ghost btn-sm forum-upload-label" title="Foto anhängen">
|
|
${UI.icon('camera')}
|
|
<input type="file" accept="image/*" id="forum-reply-file" style="display:none">
|
|
</label>
|
|
<div id="forum-reply-previews" class="forum-upload-previews"></div>
|
|
</div>
|
|
</div>` : (!_appState.user
|
|
? `<p style="color:var(--c-text-muted);font-size:0.85rem;margin-top:var(--space-3)">Bitte anmelden um zu antworten.</p>`
|
|
: `<p style="color:var(--c-text-muted);font-size:0.85rem;margin-top:var(--space-3)">${UI.icon('lock')} Dieser Thread ist gesperrt.</p>`
|
|
);
|
|
|
|
const body = `
|
|
<div class="forum-thread-detail">
|
|
${modToolbar}
|
|
<div class="forum-thread-header-row">
|
|
<span class="forum-category-badge forum-category-badge--${_esc(thread.kategorie)}">${_esc(thread.kategorie)}</span>
|
|
<span style="color:var(--c-text-muted);font-size:0.8rem">${_fmtDate(thread.created_at)}</span>
|
|
${thread.is_pinned ? `<span>${UI.icon('push-pin')}</span>` : ''}
|
|
${thread.is_locked ? `<span>${UI.icon('lock')}</span>` : ''}
|
|
</div>
|
|
|
|
<div class="forum-thread-body">
|
|
<p style="white-space:pre-wrap;word-break:break-word">${_esc(thread.text)}</p>
|
|
${fotoGallery}
|
|
</div>
|
|
|
|
<div class="forum-thread-author-row">
|
|
<div class="forum-avatar">${_esc(_initial(thread.autor_name))}</div>
|
|
<span style="font-size:0.85rem;color:var(--c-text-secondary)">${_esc(thread.autor_name || 'Unbekannt')}</span>
|
|
<div style="margin-left:auto;display:flex;gap:var(--space-2);align-items:center">
|
|
<button class="${likeClass}" id="thread-like-btn" data-count="${thread.likes || 0}">
|
|
${UI.icon('heart')} <span id="thread-like-count">${thread.likes || 0}</span>
|
|
</button>
|
|
${_appState.user && !isOwn ? `<button class="forum-report-btn" id="thread-report-btn">${UI.icon('flag')}</button>` : ''}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="forum-posts-section">
|
|
<div class="forum-posts-divider">${thread.antworten || 0} Antworten</div>
|
|
<div id="forum-posts-list">${postsHtml}</div>
|
|
</div>
|
|
|
|
${replySection}
|
|
</div>
|
|
`;
|
|
|
|
const footer = _appState.user ? `
|
|
${(isOwn || isMod) ? `<button type="button" class="btn btn-ghost btn-sm" id="ft-delete-thread" style="color:var(--c-danger)">${UI.icon('trash')} Löschen</button>` : ''}
|
|
<button type="button" class="btn btn-secondary" id="ft-close">Schließen</button>
|
|
${(!thread.is_locked && _appState.user) ? `<button type="button" class="btn btn-primary" id="ft-reply">Antworten</button>` : ''}
|
|
` : `<button type="button" class="btn btn-primary" id="ft-close">Schließen</button>`;
|
|
|
|
UI.modal.open({ title: `${UI.icon('chat-circle-dots')} ${_esc(thread.titel)}`, body, footer });
|
|
|
|
// Close
|
|
document.getElementById('ft-close')?.addEventListener('click', UI.modal.close);
|
|
|
|
// Like thread
|
|
document.getElementById('thread-like-btn')?.addEventListener('click', async () => {
|
|
if (!_appState.user) { UI.toast.info('Bitte erst anmelden.'); return; }
|
|
const btn = document.getElementById('thread-like-btn');
|
|
try {
|
|
const res = await API.forum.like('thread', thread.id);
|
|
thread.user_liked = res.liked;
|
|
thread.likes = res.count;
|
|
btn.classList.toggle('active', res.liked);
|
|
const countEl = document.getElementById('thread-like-count');
|
|
if (countEl) countEl.textContent = res.count;
|
|
// Update local state
|
|
const idx = _threads.findIndex(t => t.id === thread.id);
|
|
if (idx !== -1) { _threads[idx].likes = res.count; _threads[idx].user_liked = res.liked; }
|
|
} catch (err) { UI.toast.error(err.message); }
|
|
});
|
|
|
|
// Report thread
|
|
document.getElementById('thread-report-btn')?.addEventListener('click', () => {
|
|
_showReportForm('thread', thread.id);
|
|
});
|
|
|
|
// Delete thread
|
|
document.getElementById('ft-delete-thread')?.addEventListener('click', async () => {
|
|
const ok = await UI.modal.confirm({
|
|
title: 'Thread löschen?',
|
|
message: 'Der Thread wird unwiderruflich entfernt.',
|
|
confirmText: 'Löschen', danger: true,
|
|
});
|
|
if (!ok) return;
|
|
try {
|
|
await API.forum.deleteThread(thread.id);
|
|
_threads = _threads.filter(t => t.id !== thread.id);
|
|
UI.modal.close();
|
|
_renderList(true);
|
|
UI.toast.success('Thread gelöscht.');
|
|
} catch (err) { UI.toast.error(err.message); }
|
|
});
|
|
|
|
// 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', () => {
|
|
window.open(img.dataset.src || img.src, '_blank');
|
|
});
|
|
});
|
|
|
|
// 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);
|
|
}
|
|
document.getElementById('forum-reply-text').value = '';
|
|
const previews = document.getElementById('forum-reply-previews');
|
|
if (previews) previews.innerHTML = '';
|
|
UI.toast.success('Antwort gesendet.');
|
|
});
|
|
});
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// Post HTML
|
|
// ----------------------------------------------------------
|
|
function _postHTML(p, uid, isMod) {
|
|
if (p.is_deleted) {
|
|
return `<div class="forum-post forum-post--deleted" data-post-id="${p.id}">
|
|
<em>Beitrag wurde entfernt</em>
|
|
</div>`;
|
|
}
|
|
|
|
const isOwn = uid && uid === p.user_id;
|
|
const fotoHtml = (p.foto_urls?.length)
|
|
? `<div class="forum-foto-grid">${p.foto_urls.map(u =>
|
|
`<img src="${_esc(u)}" class="forum-foto-img" data-src="${_esc(u)}" alt="" loading="lazy">`
|
|
).join('')}</div>`
|
|
: '';
|
|
|
|
const likeClass = p.user_liked ? 'forum-like-btn active' : 'forum-like-btn';
|
|
const canDelete = isOwn || isMod;
|
|
|
|
return `
|
|
<div class="forum-post" data-post-id="${p.id}" data-user-id="${p.user_id || ''}">
|
|
<div class="forum-post-header">
|
|
<div class="forum-avatar forum-avatar--sm">${_esc(_initial(p.autor_name))}</div>
|
|
<span class="forum-post-author">${_esc(p.autor_name || 'Unbekannt')}</span>
|
|
<span class="forum-post-date">${_fmtDate(p.created_at)}</span>
|
|
</div>
|
|
<div class="forum-post-body">
|
|
<div class="forum-post-text">${_esc(p.text)}</div>
|
|
${fotoHtml}
|
|
</div>
|
|
<div class="forum-post-actions">
|
|
<button class="${likeClass} forum-post-like" data-post-id="${p.id}">
|
|
${UI.icon('heart')} <span class="forum-post-like-count">${p.likes || 0}</span>
|
|
</button>
|
|
${(!isOwn && uid) ? `<button class="forum-report-btn forum-post-report" data-post-id="${p.id}">${UI.icon('flag')}</button>` : ''}
|
|
${canDelete ? `<button class="btn btn-ghost btn-sm forum-post-delete" data-post-id="${p.id}" style="color:var(--c-danger);margin-left:auto">${UI.icon('trash')}</button>` : ''}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// Post-Aktionen binden
|
|
// ----------------------------------------------------------
|
|
function _bindPostActions(container, threadId, uid, isMod) {
|
|
// Like
|
|
container.querySelectorAll('.forum-post-like:not([data-bound])').forEach(btn => {
|
|
btn.dataset.bound = '1';
|
|
btn.addEventListener('click', async () => {
|
|
if (!uid) { UI.toast.info('Bitte erst anmelden.'); return; }
|
|
const postId = parseInt(btn.dataset.postId);
|
|
try {
|
|
const res = await API.forum.like('post', postId);
|
|
btn.classList.toggle('active', res.liked);
|
|
const countEl = btn.querySelector('.forum-post-like-count');
|
|
if (countEl) countEl.textContent = res.count;
|
|
} catch (err) { UI.toast.error(err.message); }
|
|
});
|
|
});
|
|
|
|
// Report
|
|
container.querySelectorAll('.forum-post-report:not([data-bound])').forEach(btn => {
|
|
btn.dataset.bound = '1';
|
|
btn.addEventListener('click', () => {
|
|
_showReportForm('post', parseInt(btn.dataset.postId));
|
|
});
|
|
});
|
|
|
|
// Delete
|
|
container.querySelectorAll('.forum-post-delete:not([data-bound])').forEach(btn => {
|
|
btn.dataset.bound = '1';
|
|
btn.addEventListener('click', async () => {
|
|
const postId = parseInt(btn.dataset.postId);
|
|
const postEl = container.querySelector(`[data-post-id="${postId}"]`);
|
|
const ok = await UI.modal.confirm({
|
|
title: 'Antwort löschen?',
|
|
message: 'Dieser Beitrag wird entfernt.',
|
|
confirmText: 'Löschen', danger: true,
|
|
});
|
|
if (!ok) return;
|
|
try {
|
|
await API.forum.deletePost(postId);
|
|
if (postEl) {
|
|
postEl.innerHTML = '<em style="color:var(--c-text-muted)">Beitrag wurde entfernt</em>';
|
|
postEl.className = 'forum-post forum-post--deleted';
|
|
}
|
|
const idx = _threads.findIndex(t => t.id === threadId);
|
|
if (idx !== -1 && _threads[idx].antworten > 0) _threads[idx].antworten--;
|
|
UI.toast.success('Beitrag gelöscht.');
|
|
} catch (err) { UI.toast.error(err.message); }
|
|
});
|
|
});
|
|
|
|
// Foto-Fullscreen
|
|
container.querySelectorAll('.forum-foto-img:not([data-bound])').forEach(img => {
|
|
img.dataset.bound = '1';
|
|
img.addEventListener('click', () => window.open(img.dataset.src || img.src, '_blank'));
|
|
});
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// 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
|
|
// ----------------------------------------------------------
|
|
function _showCreateForm() {
|
|
const katOptions = KATEGORIEN.filter(k => k.key !== 'alle').map(k =>
|
|
`<option value="${k.key}" ${k.key === _aktivKat ? 'selected' : ''}>${_esc(k.label)}</option>`
|
|
).join('');
|
|
|
|
const body = `
|
|
<form id="forum-thread-form" autocomplete="off">
|
|
<div class="form-group">
|
|
<label class="form-label">Kategorie</label>
|
|
<select class="form-control" name="kategorie">${katOptions}</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Titel *</label>
|
|
<input class="form-control" type="text" name="titel"
|
|
placeholder="Worum geht es?" required maxlength="200">
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Text * (min. 20 Zeichen)</label>
|
|
<textarea class="form-control" name="text" rows="5"
|
|
placeholder="Beschreibe dein Thema ausführlich…" required></textarea>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Fotos (max. 5)</label>
|
|
<div class="forum-upload-area">
|
|
<label class="btn btn-secondary btn-sm" for="forum-thread-files">${UI.icon('camera')} Fotos auswählen</label>
|
|
<input type="file" id="forum-thread-files" accept="image/*" multiple style="display:none">
|
|
</div>
|
|
<div id="forum-thread-previews" class="forum-upload-previews" style="margin-top:var(--space-2)"></div>
|
|
</div>
|
|
</form>`;
|
|
|
|
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 });
|
|
|
|
document.getElementById('ff-cancel')?.addEventListener('click', UI.modal.close);
|
|
|
|
// 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 created = await API.forum.create({
|
|
kategorie: fd.kategorie,
|
|
titel: (fd.titel || '').trim(),
|
|
text: (fd.text || '').trim(),
|
|
});
|
|
|
|
// 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('Thema erstellt!');
|
|
});
|
|
});
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// 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();
|
|
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 => {
|
|
L.marker([m.lat, m.lon])
|
|
.bindPopup(`<strong>${_esc(m.vorname || '?')}</strong>`)
|
|
.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
|
|
? `<div class="forum-mod-reports">
|
|
${reports.map(r => `
|
|
<div class="forum-mod-report-item" data-id="${r.id}">
|
|
<div style="font-size:var(--text-sm)">
|
|
<strong>${_esc(r.target_type)} #${r.target_id}</strong>
|
|
— ${_esc(r.grund)}
|
|
</div>
|
|
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">
|
|
von ${_esc(r.melder_name || '?')} · ${_fmtDate(r.created_at)}
|
|
</div>
|
|
<button class="btn btn-sm btn-secondary forum-resolve-btn" data-id="${r.id}" style="margin-top:var(--space-2)">
|
|
${UI.icon('check')} Erledigt
|
|
</button>
|
|
</div>`).join('')}
|
|
</div>`
|
|
: `<p style="color:var(--c-text-muted);text-align:center;padding:var(--space-6)">Keine offenen Berichte.</p>`;
|
|
|
|
const footer = `<button type="button" class="btn btn-primary flex-1" id="mod-close">Schließen</button>`;
|
|
UI.modal.open({ title: `${UI.icon('scales')} Moderationsberichte`, body, footer });
|
|
document.getElementById('mod-close')?.addEventListener('click', UI.modal.close);
|
|
|
|
document.querySelectorAll('.forum-resolve-btn').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
const id = parseInt(btn.dataset.id);
|
|
try {
|
|
await API.forum.resolveReport(id);
|
|
btn.closest('.forum-mod-report-item')?.remove();
|
|
UI.toast.success('Erledigt.');
|
|
} catch (err) { UI.toast.error(err.message); }
|
|
});
|
|
});
|
|
}
|
|
|
|
return { init, refresh, onDogChange };
|
|
|
|
})();
|