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