Praxisfall: Antwort wird serverseitig erstellt, aber die HTTP-Antwort geht unterwegs verloren (schlechtes Netz). UI zeigt Fehler statt Erfolg, Text bleibt stehen -> Nutzer tippt erneut -> 2. Versuch laeuft in den 30s-Cooldown (429), der bereits gepostete Beitrag bleibt unsichtbar. - forum_posts.client_uuid (Migration). Reply mit stabiler client_uuid: Retry liefert den BEREITS erstellten Post zurueck (kein Cooldown/Doppelpost). - Frontend: UUID bleibt ueber Retries stabil, Reset erst nach Erfolg; Foto- Doppel-Upload bei Retry verhindert. - Anti-Spam-Cooldown bleibt fuer echte neue Posts aktiv. - Tests: tests/test_forum_idempotency.py (Retry=selber Post, Cooldown greift, ohne UUID rueckwaertskompatibel).
1466 lines
64 KiB
JavaScript
1466 lines
64 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 _map = null;
|
||
let _clusterGroup = null;
|
||
let _activeSection = 'list'; // 'list' | 'map'
|
||
|
||
const LIMIT = 30;
|
||
|
||
const KATEGORIEN = [
|
||
{ key: 'alle', label: 'Alle' },
|
||
{ key: 'allgemein', label: 'Allgemein' },
|
||
{ key: 'rasse', label: 'Rasse' },
|
||
{ key: 'region', label: 'Region' },
|
||
{ key: 'gesundheit', label: 'Gesundheit' },
|
||
{ key: 'erziehung', label: 'Erziehung' },
|
||
{ key: 'spaziergang', label: 'Spaziergang' },
|
||
{ key: 'ausflug', label: 'Ausflug' },
|
||
{ key: 'training', label: 'Training & Lektionen' },
|
||
{ key: 'ernaehrung', label: 'Ernährung & Rezepte' },
|
||
{ key: 'probleme', label: 'Probleme' },
|
||
{ key: 'tauschboerse', label: 'Tauschbörse' },
|
||
];
|
||
|
||
// ----------------------------------------------------------
|
||
// Helpers
|
||
// ----------------------------------------------------------
|
||
function _fmtDate(iso) {
|
||
if (!iso) return '—';
|
||
const d = new Date(iso);
|
||
const now = new Date();
|
||
const diff = (now - d) / 1000;
|
||
if (diff < 60) return 'gerade eben';
|
||
if (diff < 3600) return `vor ${Math.floor(diff / 60)} Min`;
|
||
if (diff < 86400) return `vor ${Math.floor(diff / 3600)} Std`;
|
||
return d.toLocaleDateString('de-DE');
|
||
}
|
||
|
||
function _initial(name) {
|
||
return (name || '?').charAt(0).toUpperCase();
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// INIT
|
||
// ----------------------------------------------------------
|
||
async function init(container, appState, params = {}) {
|
||
_container = container;
|
||
_appState = appState;
|
||
_render();
|
||
_loadHdmCard();
|
||
_loadThreads(true);
|
||
|
||
// Rassen-Suche vorausfüllen (Feature 3: Same-Breed-Chip)
|
||
if (params.search) {
|
||
const searchInput = document.getElementById('forum-search');
|
||
if (searchInput) {
|
||
searchInput.value = params.search;
|
||
searchInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||
}
|
||
}
|
||
}
|
||
|
||
function refresh() {
|
||
_loadThreads(true);
|
||
}
|
||
|
||
function onDogChange() {}
|
||
|
||
// ----------------------------------------------------------
|
||
// RENDER — Grundstruktur
|
||
// ----------------------------------------------------------
|
||
function _render() {
|
||
const _u = _appState.user;
|
||
const isMod = !!(_u && (_u.rolle === 'admin' || _u.rolle === 'moderator' || _u.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 text-muted" id="forum-rules-btn" title="Regeln & Netiquette">${UI.icon('info')} Regeln</button>
|
||
<button class="btn btn-primary btn-sm" id="forum-new-btn">${UI.icon('plus')} Neues Thema</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Kategorie-Tabs -->
|
||
<div class="forum-category-tabs by-tabs" id="forum-tabs">
|
||
${KATEGORIEN.map(k => `
|
||
<button class="by-tab ${k.key === _aktivKat ? 'active' : ''}"
|
||
data-kat="${k.key}"><span class="by-tab-text">${UI.escape(k.label)}</span></button>
|
||
`).join('')}
|
||
<button class="by-tab ${_activeSection === 'map' ? 'active' : ''}"
|
||
data-section="map"><span class="by-tab-text">${UI.icon('users')} Mitgliederkarte</span></button>
|
||
</div>
|
||
|
||
<!-- Rechte Spalte: HdM-Kachel + Suche + Threads -->
|
||
<div class="forum-main-col">
|
||
|
||
<div id="forum-hdm-card"></div>
|
||
|
||
<div class="forum-search-wrap">
|
||
<input type="search" class="forum-search" id="forum-search"
|
||
placeholder="Forum durchsuchen…" autocomplete="off">
|
||
</div>
|
||
|
||
<!-- Thread-Liste / Karte / Suche -->
|
||
<div id="forum-main">
|
||
<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Lädt…</p>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
</div>
|
||
`;
|
||
|
||
// Desktop: Tabs gleichmäßig auf 2 Zeilen verteilen
|
||
const _tabsEl = document.getElementById('forum-tabs');
|
||
const _tabCount = _tabsEl.querySelectorAll('.by-tab').length;
|
||
_tabsEl.style.setProperty('--forum-tab-cols', Math.ceil(_tabCount / 2));
|
||
|
||
// Marquee-Scroll: nur Tabs animieren, bei denen Text wirklich abgeschnitten ist
|
||
_tabsEl.addEventListener('mouseenter', e => {
|
||
const btn = e.target.closest('.by-tab');
|
||
const span = btn?.querySelector('.by-tab-text');
|
||
if (!span) return;
|
||
const style = getComputedStyle(btn);
|
||
const padH = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
|
||
const overflow = span.scrollWidth - (btn.clientWidth - padH);
|
||
if (overflow <= 2) return;
|
||
span.style.setProperty('--tab-scroll-px', `-${overflow}px`);
|
||
span.classList.add('scrolling');
|
||
}, true);
|
||
_tabsEl.addEventListener('mouseleave', e => {
|
||
const span = e.target.closest('.by-tab')?.querySelector('.by-tab-text');
|
||
if (span) span.classList.remove('scrolling');
|
||
}, true);
|
||
|
||
// Tab-Klicks
|
||
_tabsEl.addEventListener('click', e => {
|
||
const btn = e.target.closest('[data-kat], [data-section]');
|
||
if (!btn) return;
|
||
|
||
if (btn.dataset.section === 'map') {
|
||
_aktivKat = 'alle';
|
||
_activeSection = 'map';
|
||
document.querySelectorAll('#forum-tabs .by-tab').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
_renderMembersMap();
|
||
return;
|
||
}
|
||
|
||
_aktivKat = btn.dataset.kat;
|
||
_activeSection = 'list';
|
||
document.querySelectorAll('#forum-tabs .by-tab').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
_offset = 0;
|
||
_threads = [];
|
||
_loadThreads(true);
|
||
});
|
||
|
||
// Suche
|
||
document.getElementById('forum-search').addEventListener('input', e => {
|
||
clearTimeout(_searchTimer);
|
||
const q = e.target.value.trim();
|
||
if (!q) {
|
||
_searching = false;
|
||
_renderList();
|
||
return;
|
||
}
|
||
_searchTimer = setTimeout(() => _doSearch(q), 400);
|
||
});
|
||
|
||
// Neues Thema
|
||
document.getElementById('forum-new-btn').addEventListener('click', () => {
|
||
if (!_appState.user) { UI.toast.info('Bitte erst anmelden.'); return; }
|
||
_showCreateForm();
|
||
});
|
||
|
||
// Moderations-Panel
|
||
document.getElementById('forum-mod-btn')?.addEventListener('click', _showModPanel);
|
||
|
||
// Regeln & Netiquette
|
||
document.getElementById('forum-rules-btn').addEventListener('click', _showRules);
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// Hund des Monats — Kachel + Modal
|
||
// ----------------------------------------------------------
|
||
async function _loadHdmCard() {
|
||
const card = document.getElementById('forum-hdm-card');
|
||
if (!card) return;
|
||
try {
|
||
const data = await API.get('/movies/hund-des-monats');
|
||
const [year, month] = data.monat.split('-');
|
||
const monthName = new Intl.DateTimeFormat('de-DE', { month: 'long' })
|
||
.format(new Date(+year, +month - 1, 1));
|
||
const top = data.top?.[0];
|
||
const winnerLine = top
|
||
? `🥇 ${UI.escape(top.name)}${top.rasse ? ` · ${UI.escape(top.rasse)}` : ''}`
|
||
: 'Noch keine Stimmen';
|
||
const metaLine = top
|
||
? `${top.stimmen} Stimme${top.stimmen !== 1 ? 'n' : ''}`
|
||
: 'Sei der Erste!';
|
||
|
||
card.innerHTML = `
|
||
<div class="forum-hdm-tile" id="forum-hdm-tile">
|
||
<div class="forum-hdm-tile-trophy">🏆</div>
|
||
<div class="forum-hdm-tile-body">
|
||
<div class="forum-hdm-tile-title">Hund des Monats · ${UI.escape(monthName)}</div>
|
||
<div class="forum-hdm-tile-winner">${winnerLine}</div>
|
||
<div class="forum-hdm-tile-meta">${metaLine}</div>
|
||
</div>
|
||
<div class="forum-hdm-tile-cta">${UI.icon('arrow-right')}</div>
|
||
</div>`;
|
||
|
||
document.getElementById('forum-hdm-tile')?.addEventListener('click', () => _openHdmModal(data));
|
||
} catch {
|
||
// Kachel bleibt leer bei Fehler
|
||
}
|
||
}
|
||
|
||
async function _openHdmModal(data) {
|
||
try { data = await API.get('/movies/hund-des-monats'); } catch { /* gecachte Daten */ }
|
||
|
||
const [year, month] = data.monat.split('-');
|
||
const monthName = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' })
|
||
.format(new Date(+year, +month - 1, 1));
|
||
|
||
const topList = data.top?.length
|
||
? data.top.slice(0, 5).map((dog, i) => {
|
||
const medal = ['🥇','🥈','🥉','4️⃣','5️⃣'][i];
|
||
const av = dog.foto_url
|
||
? `<img src="${UI.escape(dog.foto_url)}" alt="${UI.escape(dog.name)}" class="hdm-top-av-img">`
|
||
: `<span class="hdm-top-av-placeholder">${UI.escape(dog.name.charAt(0).toUpperCase())}</span>`;
|
||
const vorname = dog.besitzer_name ? UI.escape(dog.besitzer_name.split(' ')[0]) : '';
|
||
return `
|
||
<div class="hdm-top-entry">
|
||
<span class="hdm-top-medal">${medal}</span>
|
||
<div class="hdm-top-av">${av}</div>
|
||
<div class="hdm-top-info">
|
||
<div class="hdm-top-name">${UI.escape(dog.name)}</div>
|
||
${dog.rasse ? `<div class="hdm-top-rasse">${UI.escape(dog.rasse)}</div>` : ''}
|
||
${vorname ? `<div class="hdm-top-besitzer">von ${vorname}</div>` : ''}
|
||
</div>
|
||
<div class="hdm-top-stimmen">${dog.stimmen} ${UI.icon('star')}</div>
|
||
</div>`;
|
||
}).join('')
|
||
: `<p style="color:var(--c-text-secondary);padding:var(--space-4)">Noch keine Stimmen. Sei der Erste!</p>`;
|
||
|
||
const voteHint = !_appState.user
|
||
? `<div class="hdm-section">
|
||
<p style="color:var(--c-text-secondary);font-size:var(--text-sm)">
|
||
<a href="#" id="hdm-login-link" style="color:var(--c-primary);font-weight:var(--weight-semibold)">Anmelden</a>
|
||
um abstimmen zu können.
|
||
</p>
|
||
</div>`
|
||
: `<div class="hdm-section">
|
||
<h3 class="hdm-section-title">Für welchen Hund möchtest du abstimmen?</h3>
|
||
<div class="hdm-kandidaten-search">
|
||
<input type="search" id="hdm-search" class="form-control text-sm"
|
||
placeholder="Name oder Rasse suchen …" autocomplete="off"
|
||
>
|
||
</div>
|
||
<div id="hdm-kandidaten-grid" class="hdm-vote-grid">
|
||
${UI.skeleton(3)}
|
||
</div>
|
||
</div>`;
|
||
|
||
const body = `
|
||
<div class="hdm-header">
|
||
<div class="hdm-trophy">🏆</div>
|
||
<h2 class="hdm-title">Hund des Monats</h2>
|
||
<div class="hdm-monat">${UI.escape(monthName)}</div>
|
||
</div>
|
||
${voteHint}
|
||
<div class="hdm-section">
|
||
<h3 class="hdm-section-title">Top 5 diesen Monat</h3>
|
||
<div id="hdm-top-list">${topList}</div>
|
||
</div>`;
|
||
|
||
UI.modal.open({ title: '🏆 Hund des Monats', body,
|
||
footer: `<button class="btn btn-secondary flex-1" data-modal-close>Schließen</button>` });
|
||
|
||
document.getElementById('hdm-login-link')?.addEventListener('click', e => {
|
||
e.preventDefault(); UI.modal.close(); App.navigate('settings');
|
||
});
|
||
|
||
if (!_appState.user) return;
|
||
|
||
// Kandidaten laden und rendern
|
||
let _kandidaten = [];
|
||
const _renderKandidaten = (list) => {
|
||
const grid = document.getElementById('hdm-kandidaten-grid');
|
||
if (!grid) return;
|
||
if (!list.length) {
|
||
grid.innerHTML = `<p style="color:var(--c-text-secondary);font-size:var(--text-sm);padding:var(--space-3) 0">Keine Hunde gefunden.</p>`;
|
||
return;
|
||
}
|
||
grid.innerHTML = list.map(dog => {
|
||
const isVoted = data.user_vote === dog.id;
|
||
const av = dog.foto_url
|
||
? `<img src="${UI.escape(dog.foto_url)}" alt="${UI.escape(dog.name)}" class="hdm-vote-av-img">`
|
||
: `<span class="hdm-vote-av-placeholder">${UI.escape(dog.name.charAt(0).toUpperCase())}</span>`;
|
||
const vorname = dog.besitzer_name ? UI.escape(dog.besitzer_name.split(' ')[0]) : '';
|
||
return `
|
||
<div class="hdm-vote-card${isVoted ? ' hdm-vote-card--voted' : ''}">
|
||
<div class="hdm-vote-av">${av}</div>
|
||
<div class="hdm-vote-name">${UI.escape(dog.name)}</div>
|
||
${dog.rasse ? `<div class="hdm-vote-rasse">${UI.escape(dog.rasse)}</div>` : ''}
|
||
${vorname ? `<div class="hdm-vote-besitzer text-xs-muted">von ${vorname}</div>` : ''}
|
||
${dog.stimmen > 0 ? `<div class="text-xs-muted">${dog.stimmen} ${UI.icon('star')}</div>` : ''}
|
||
<button class="btn btn-sm ${isVoted ? 'btn-primary' : 'btn-secondary'} hdm-vote-btn"
|
||
data-dog-id="${dog.id}" ${isVoted ? 'disabled' : ''}>
|
||
${isVoted ? `${UI.icon('check-circle')} Gewählt` : 'Abstimmen'}
|
||
</button>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
grid.querySelectorAll('.hdm-vote-btn:not([disabled])').forEach(btn => {
|
||
btn.addEventListener('click', async () => {
|
||
const dogId = parseInt(btn.dataset.dogId);
|
||
await UI.asyncButton(btn, async () => {
|
||
try {
|
||
await API.post('/movies/hund-des-monats/vote', { dog_id: dogId });
|
||
data.user_vote = dogId;
|
||
UI.toast.success('Stimme abgegeben!');
|
||
UI.modal.close();
|
||
_loadHdmCard();
|
||
} catch (err) {
|
||
UI.toast.error(err.message || 'Fehler beim Abstimmen.');
|
||
}
|
||
});
|
||
});
|
||
});
|
||
};
|
||
|
||
try {
|
||
_kandidaten = await API.get('/movies/hund-des-monats/kandidaten');
|
||
} catch {
|
||
document.getElementById('hdm-kandidaten-grid').innerHTML =
|
||
`<p style="color:var(--c-danger);font-size:var(--text-sm)">Kandidaten konnten nicht geladen werden.</p>`;
|
||
return;
|
||
}
|
||
_renderKandidaten(_kandidaten);
|
||
|
||
document.getElementById('hdm-search')?.addEventListener('input', e => {
|
||
const q = e.target.value.trim().toLowerCase();
|
||
_renderKandidaten(q
|
||
? _kandidaten.filter(d =>
|
||
(d.name || '').toLowerCase().includes(q) ||
|
||
(d.rasse || '').toLowerCase().includes(q))
|
||
: _kandidaten
|
||
);
|
||
});
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// Threads laden
|
||
// ----------------------------------------------------------
|
||
async function _loadThreads(reset = false) {
|
||
if (reset) { _offset = 0; _threads = []; }
|
||
|
||
const mainEl = document.getElementById('forum-main');
|
||
if (mainEl && reset) {
|
||
mainEl.innerHTML = `<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Lädt…</p>`;
|
||
}
|
||
|
||
try {
|
||
const params = { limit: LIMIT, offset: _offset };
|
||
if (_aktivKat !== 'alle') params.kategorie = _aktivKat;
|
||
|
||
const rows = await API.forum.threads(params);
|
||
_threads = reset ? rows : [..._threads, ...rows];
|
||
_offset += rows.length;
|
||
_renderList(rows.length < LIMIT);
|
||
} catch (err) {
|
||
UI.toast.error(err.message || 'Fehler beim Laden.');
|
||
}
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// Thread-Liste rendern
|
||
// ----------------------------------------------------------
|
||
function _renderList(noMore = false) {
|
||
if (_searching) return;
|
||
const el = document.getElementById('forum-main');
|
||
if (!el) return;
|
||
|
||
if (!_threads.length) {
|
||
el.innerHTML = `
|
||
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
|
||
<div style="font-size:3rem;margin-bottom:var(--space-3)">${UI.icon('chat-circle-dots')}</div>
|
||
<p class="text-secondary">Noch keine Beiträge in dieser Kategorie.</p>
|
||
<button class="btn btn-primary mt-4" id="forum-first-btn">
|
||
Ersten Beitrag erstellen
|
||
</button>
|
||
</div>`;
|
||
document.getElementById('forum-first-btn')?.addEventListener('click', () => {
|
||
if (!_appState.user) { UI.toast.info('Bitte erst anmelden.'); return; }
|
||
_showCreateForm();
|
||
});
|
||
return;
|
||
}
|
||
|
||
el.innerHTML = `
|
||
<div class="forum-list-inner" id="forum-thread-list">
|
||
${_threads.map(_threadCardHTML).join('')}
|
||
</div>
|
||
${!noMore ? `<div style="text-align:center;padding:var(--space-4)">
|
||
<button class="btn btn-secondary btn-sm" id="forum-loadmore">Mehr laden</button>
|
||
</div>` : ''}
|
||
`;
|
||
|
||
el.querySelectorAll('.forum-thread-card').forEach(card => {
|
||
card.addEventListener('click', () => _openThread(parseInt(card.dataset.id)));
|
||
});
|
||
|
||
document.getElementById('forum-loadmore')?.addEventListener('click', () => {
|
||
_loadThreads(false);
|
||
});
|
||
}
|
||
|
||
function _threadCardHTML(t) {
|
||
const preview = t.text_preview
|
||
? UI.escape(t.text_preview.slice(0, 120)) + (t.text_preview.length >= 120 ? '…' : '')
|
||
: '';
|
||
const pinBadge = t.is_pinned ? `<span class="forum-pin-badge" title="${t.pin_scope === 'kategorie' ? 'Im Thema angepinnt' : 'Angepinnt'}">${UI.icon('push-pin')}</span>` : '';
|
||
const lockBadge = t.is_locked ? `<span class="forum-lock-badge" title="Gesperrt">${UI.icon('lock')}</span>` : '';
|
||
const fotoHtml = t.foto_preview
|
||
? /\.(mp4|mov|webm|m4v|avi)$/i.test(t.foto_preview)
|
||
? `<div class="forum-card-thumb forum-card-thumb--video" style="display:flex;align-items:center;justify-content:center;background:var(--c-surface-2)">${UI.icon('video-camera')}</div>`
|
||
: `<img class="forum-card-thumb" src="${UI.escape(t.foto_preview_url || t.foto_preview)}"
|
||
${(t.foto_preview_url && t.foto_preview) ? `srcset="${UI.escape(t.foto_preview_url)} 800w" sizes="120px"` : ''}
|
||
alt="" loading="lazy"
|
||
data-fb-src="${UI.escape(t.foto_preview)}">`
|
||
: '';
|
||
|
||
return `
|
||
<div class="forum-thread-card" data-id="${t.id}">
|
||
<div class="forum-card-top">
|
||
<span class="forum-category-badge forum-category-badge--${UI.escape(t.kategorie)}">${UI.escape(t.kategorie)}</span>
|
||
${pinBadge}${lockBadge}
|
||
</div>
|
||
<div class="forum-card-content">
|
||
<div class="forum-card-main">
|
||
<div class="forum-card-title">${UI.escape(t.titel)}</div>
|
||
${preview ? `<div class="forum-card-preview">${preview}</div>` : ''}
|
||
<div class="forum-card-meta">
|
||
<span>${UI.icon('user')} ${UI.escape(t.autor_name || 'Unbekannt')}</span>
|
||
<span>${UI.icon('calendar-dots')} ${_fmtDate(t.created_at)}</span>
|
||
<span>${UI.icon('chat-circle-dots')} ${t.antworten || 0}</span>
|
||
<span class="${t.user_liked ? 'forum-liked' : ''}">${UI.icon('heart')} ${t.likes || 0}</span>
|
||
</div>
|
||
</div>
|
||
${fotoHtml}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// Suche
|
||
// ----------------------------------------------------------
|
||
async function _doSearch(q) {
|
||
_searching = true;
|
||
const el = document.getElementById('forum-main');
|
||
if (el) el.innerHTML = `<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-6)">Suche…</p>`;
|
||
|
||
try {
|
||
const results = await API.forum.search(q);
|
||
if (!document.getElementById('forum-main')) return;
|
||
if (!results.length) {
|
||
document.getElementById('forum-main').innerHTML = `
|
||
<div style="text-align:center;padding:var(--space-8)">
|
||
<div style="font-size:2rem;margin-bottom:var(--space-2)">${UI.icon('magnifying-glass')}</div>
|
||
<p class="text-secondary">Keine Ergebnisse für „${UI.escape(q)}"</p>
|
||
</div>`;
|
||
return;
|
||
}
|
||
document.getElementById('forum-main').innerHTML = `
|
||
<div class="forum-list-inner">
|
||
${results.map(t => _threadCardHTML({ ...t, foto_preview: null, is_pinned: 0, is_locked: 0, user_liked: false })).join('')}
|
||
</div>`;
|
||
document.getElementById('forum-main').querySelectorAll('.forum-thread-card').forEach(card => {
|
||
card.addEventListener('click', () => _openThread(parseInt(card.dataset.id)));
|
||
});
|
||
} catch (err) {
|
||
UI.toast.error(err.message || 'Suchfehler.');
|
||
}
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// Thread-Detail-Modal
|
||
// ----------------------------------------------------------
|
||
async function _openThread(threadId) {
|
||
let thread;
|
||
try {
|
||
thread = await API.forum.thread(threadId);
|
||
} catch (err) {
|
||
UI.toast.error(err.message);
|
||
return;
|
||
}
|
||
|
||
const uid = _appState.user?.id;
|
||
const _u = _appState.user;
|
||
const isMod = !!(_u && (_u.rolle === 'admin' || _u.rolle === 'moderator' || _u.is_moderator));
|
||
const isOwn = uid && uid === thread.user_id;
|
||
|
||
const pinControls = thread.is_pinned
|
||
? `<span class="forum-pin-state" style="display:inline-flex;align-items:center;gap:4px;font-size:var(--text-sm);color:var(--c-text-secondary)">
|
||
${UI.icon('push-pin')} Angepinnt${thread.pin_scope === 'kategorie' ? ` (Thema „${UI.escape(thread.kategorie)}")` : ' (global)'}
|
||
</span>
|
||
<button class="btn btn-ghost btn-sm forum-mod-unpin" title="Anpinnen aufheben">Lösen</button>`
|
||
: `<button class="btn btn-ghost btn-sm forum-mod-pin-global" title="Überall ganz oben halten">
|
||
${UI.icon('push-pin')} Global anpinnen
|
||
</button>
|
||
<button class="btn btn-ghost btn-sm forum-mod-pin-cat" title="Nur im Thema „${UI.escape(thread.kategorie)}" oben halten">
|
||
${UI.icon('push-pin')} Im Thema anpinnen
|
||
</button>`;
|
||
|
||
const modToolbar = (isMod) ? `
|
||
<div class="forum-mod-toolbar">
|
||
${pinControls}
|
||
<button class="btn btn-ghost btn-sm forum-mod-lock" title="${thread.is_locked ? 'Entsperren' : 'Sperren'}">
|
||
${UI.icon('lock')} ${thread.is_locked ? 'Entsperren' : 'Sperren'}
|
||
</button>
|
||
<button class="btn btn-ghost btn-sm forum-mod-delete-thread text-danger">${UI.icon('trash')} Thread</button>
|
||
</div>` : '';
|
||
|
||
const _forumMediaHtml = (u) => {
|
||
if (u.endsWith('.pdf'))
|
||
return `<a href="${UI.escape(u)}" target="_blank" rel="noopener" class="forum-pdf-card">
|
||
${UI.icon('file-text')} <span>${UI.escape(u.split('/').pop())}</span></a>`;
|
||
if (/\.(mp4|mov|webm|m4v|avi)$/i.test(u)) {
|
||
const poster = u.replace(/\.[^.]+$/, '_thumb.jpg');
|
||
return `<video src="${UI.escape(u)}" poster="${UI.escape(poster)}" controls playsinline
|
||
style="max-width:100%;max-height:320px;border-radius:var(--radius-md);display:block"></video>`;
|
||
}
|
||
return `<img src="${UI.escape(u)}" class="forum-foto-img" data-src="${UI.escape(u)}" alt="" loading="lazy">`;
|
||
};
|
||
const fotoGallery = (thread.foto_urls?.length)
|
||
? `<div class="forum-foto-grid">${thread.foto_urls.map(_forumMediaHtml).join('')}</div>`
|
||
: '';
|
||
|
||
const likeClass = thread.user_liked ? 'forum-like-btn active' : 'forum-like-btn';
|
||
|
||
const postsHtml = (thread.posts?.length)
|
||
? thread.posts.map(p => _postHTML(p, uid, isMod)).join('')
|
||
: `<p style="color:var(--c-text-muted);font-style:italic;padding:var(--space-3) 0">Noch keine Antworten.</p>`;
|
||
|
||
const replySection = _appState.user && !thread.is_locked ? `
|
||
<div class="forum-reply-form">
|
||
<label class="form-label">Antwort schreiben</label>
|
||
<textarea class="form-control" id="forum-reply-text" rows="3"
|
||
placeholder="Deine Antwort…"></textarea>
|
||
<div class="forum-reply-actions">
|
||
<label class="btn btn-ghost btn-sm forum-upload-label" title="Foto anhängen">
|
||
${UI.icon('camera')}
|
||
<input type="file" accept="image/*" id="forum-reply-file" class="hidden">
|
||
</label>
|
||
<div id="forum-reply-previews" class="forum-upload-previews"></div>
|
||
</div>
|
||
</div>` : (!_appState.user
|
||
? `<p style="color:var(--c-text-muted);font-size:0.85rem;margin-top:var(--space-3)">Bitte anmelden um zu antworten.</p>`
|
||
: `<p style="color:var(--c-text-muted);font-size:0.85rem;margin-top:var(--space-3)">${UI.icon('lock')} Dieser Thread ist gesperrt.</p>`
|
||
);
|
||
|
||
const body = `
|
||
<div class="forum-thread-detail">
|
||
${modToolbar}
|
||
<div class="forum-thread-header-row">
|
||
<span class="forum-category-badge forum-category-badge--${UI.escape(thread.kategorie)}">${UI.escape(thread.kategorie)}</span>
|
||
<span style="color:var(--c-text-muted);font-size:0.8rem">${_fmtDate(thread.created_at)}</span>
|
||
${thread.is_pinned ? `<span>${UI.icon('push-pin')}</span>` : ''}
|
||
${thread.is_locked ? `<span>${UI.icon('lock')}</span>` : ''}
|
||
</div>
|
||
|
||
<div class="forum-thread-body">
|
||
<p style="white-space:pre-wrap;word-break:break-word">${UI.escape(thread.text)}</p>
|
||
${fotoGallery}
|
||
</div>
|
||
|
||
<div class="forum-thread-author-row">
|
||
<div class="forum-avatar">${UI.escape(_initial(thread.autor_name))}</div>
|
||
<span style="font-size:0.85rem;color:var(--c-text-secondary)">${UI.escape(thread.autor_name || 'Unbekannt')}</span>
|
||
${thread.autor_founder_number ? `<span style="font-size:10px;font-weight:700;background:#7c3aed;color:#fff;padding:1px 5px;border-radius:4px">Gründer #${thread.autor_founder_number}</span>` : ''}
|
||
<div style="margin-left:auto;display:flex;gap:var(--space-2);align-items:center">
|
||
<button class="${likeClass}" id="thread-like-btn" data-count="${thread.likes || 0}">
|
||
${UI.icon('heart')} <span id="thread-like-count">${thread.likes || 0}</span>
|
||
</button>
|
||
${_appState.user && !isOwn ? `<button class="forum-report-btn" id="thread-report-btn">${UI.icon('flag')}</button>` : ''}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="forum-posts-section">
|
||
<div class="forum-posts-divider">${thread.antworten || 0} Antworten</div>
|
||
<div id="forum-posts-list">${postsHtml}</div>
|
||
</div>
|
||
|
||
${replySection}
|
||
</div>
|
||
`;
|
||
|
||
const footer = _appState.user ? `
|
||
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
|
||
<div style="display:flex;gap:var(--space-2);align-items:center">
|
||
${(isOwn || isMod) ? `<button type="button" class="forum-icon-btn forum-icon-btn--danger" id="ft-delete-thread" title="Löschen">${UI.icon('trash')}</button>` : ''}
|
||
${isOwn ? `<button type="button" class="forum-icon-btn" id="ft-edit-thread" title="Bearbeiten">${UI.icon('pencil-simple')}</button>` : ''}
|
||
<div style="margin-left:auto;display:flex;gap:var(--space-2)">
|
||
<button type="button" class="btn btn-secondary btn-sm" id="ft-close">Schließen</button>
|
||
${(!thread.is_locked && _appState.user) ? `<button type="button" class="btn btn-primary btn-sm" id="ft-reply">Antworten</button>` : ''}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
` : `<button type="button" class="btn btn-primary w-full" id="ft-close">Schließen</button>`;
|
||
|
||
UI.modal.open({ title: `${UI.icon('chat-circle-dots')} ${UI.escape(thread.titel)}`, body, footer });
|
||
|
||
// Close
|
||
document.getElementById('ft-close')?.addEventListener('click', UI.modal.close);
|
||
|
||
// Like thread
|
||
document.getElementById('thread-like-btn')?.addEventListener('click', async () => {
|
||
if (!_appState.user) { UI.toast.info('Bitte erst anmelden.'); return; }
|
||
const btn = document.getElementById('thread-like-btn');
|
||
try {
|
||
const res = await API.forum.like('thread', thread.id);
|
||
thread.user_liked = res.liked;
|
||
thread.likes = res.count;
|
||
btn.classList.toggle('active', res.liked);
|
||
const countEl = document.getElementById('thread-like-count');
|
||
if (countEl) countEl.textContent = res.count;
|
||
// Update local state
|
||
const idx = _threads.findIndex(t => t.id === thread.id);
|
||
if (idx !== -1) { _threads[idx].likes = res.count; _threads[idx].user_liked = res.liked; }
|
||
} catch (err) { UI.toast.error(err.message); }
|
||
});
|
||
|
||
// Liker-Liste anzeigen (Klick auf die Zahl)
|
||
const _thLikeCount = document.getElementById('thread-like-count');
|
||
if (_thLikeCount) {
|
||
_thLikeCount.style.cursor = 'pointer';
|
||
_thLikeCount.title = 'Wer hat geliked?';
|
||
_thLikeCount.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
if ((thread.likes || 0) > 0) _showLikers('thread', thread.id);
|
||
});
|
||
}
|
||
|
||
// Report thread
|
||
document.getElementById('thread-report-btn')?.addEventListener('click', () => {
|
||
_showReportForm('thread', thread.id);
|
||
});
|
||
|
||
// Delete thread
|
||
document.getElementById('ft-delete-thread')?.addEventListener('click', async () => {
|
||
const ok = await UI.modal.confirm({
|
||
title: 'Thread löschen?',
|
||
message: 'Der Thread wird unwiderruflich entfernt.',
|
||
confirmText: 'Löschen', danger: true,
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
await API.forum.deleteThread(thread.id);
|
||
_threads = _threads.filter(t => t.id !== thread.id);
|
||
UI.modal.close();
|
||
_renderList(true);
|
||
UI.toast.success('Thread gelöscht.');
|
||
} catch (err) { UI.toast.error(err.message); }
|
||
});
|
||
|
||
// Edit thread (owner)
|
||
document.getElementById('ft-edit-thread')?.addEventListener('click', () => {
|
||
_showEditThreadModal(thread);
|
||
});
|
||
|
||
// Moderator: pin/lock/delete
|
||
const _applyPin = async (payload) => {
|
||
try {
|
||
await API.forum.patchThread(thread.id, payload);
|
||
UI.toast.success('Gespeichert.');
|
||
UI.modal.close();
|
||
_loadThreads(true);
|
||
} catch (err) { UI.toast.error(err.message); }
|
||
};
|
||
document.querySelector('.forum-mod-pin-global')?.addEventListener('click',
|
||
() => _applyPin({ is_pinned: 1, pin_scope: 'global' }));
|
||
document.querySelector('.forum-mod-pin-cat')?.addEventListener('click',
|
||
() => _applyPin({ is_pinned: 1, pin_scope: 'kategorie' }));
|
||
document.querySelector('.forum-mod-unpin')?.addEventListener('click',
|
||
() => _applyPin({ is_pinned: 0 }));
|
||
|
||
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
|
||
// Idempotenz-Schlüssel: bleibt über Retries STABIL und wird erst nach
|
||
// erfolgreichem Senden zurückgesetzt. So liefert ein Retry (z.B. wenn die
|
||
// Antwort des 1. Versuchs im Funkloch verloren ging) denselben Post zurück
|
||
// statt eines Cooldown-Fehlers/Doppelposts.
|
||
let _replyUuid = null;
|
||
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; }
|
||
if (!_replyUuid) {
|
||
_replyUuid = (window.crypto && crypto.randomUUID)
|
||
? crypto.randomUUID()
|
||
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||
}
|
||
|
||
await UI.asyncButton(btn, async () => {
|
||
const post = await API.forum.addPost(thread.id, { text, client_time: API.clientNow(), client_uuid: _replyUuid });
|
||
|
||
// Foto hochladen falls vorhanden — bei idempotentem Retry hat der Post
|
||
// seine Fotos bereits, dann NICHT erneut hochladen (kein Doppel-Upload).
|
||
const files = Array.from(document.getElementById('forum-reply-file')?.files || []);
|
||
if (!post.foto_urls || post.foto_urls.length === 0) {
|
||
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 = '';
|
||
_replyUuid = null; // Erfolg → nächste Antwort bekommt eine frische UUID
|
||
UI.toast.success('Antwort gesendet.');
|
||
});
|
||
});
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// Post HTML
|
||
// ----------------------------------------------------------
|
||
function _postHTML(p, uid, isMod) {
|
||
if (p.is_deleted) {
|
||
return `<div class="forum-post forum-post--deleted" data-post-id="${p.id}">
|
||
<em>Beitrag wurde entfernt</em>
|
||
</div>`;
|
||
}
|
||
|
||
const isOwn = uid && uid === p.user_id;
|
||
const fotoHtml = (p.foto_urls?.length)
|
||
? `<div class="forum-foto-grid">${p.foto_urls.map(u =>
|
||
`<img src="${UI.escape(u)}" class="forum-foto-img" data-src="${UI.escape(u)}" alt="" loading="lazy">`
|
||
).join('')}</div>`
|
||
: '';
|
||
|
||
const likeClass = p.user_liked ? 'forum-like-btn active' : 'forum-like-btn';
|
||
const canDelete = isOwn || isMod;
|
||
|
||
return `
|
||
<div class="forum-post" data-post-id="${p.id}" data-user-id="${p.user_id || ''}">
|
||
<div class="forum-post-header">
|
||
<div class="forum-avatar forum-avatar--sm">${UI.escape(_initial(p.autor_name))}</div>
|
||
<span class="forum-post-author">${UI.escape(p.autor_name || 'Unbekannt')}</span>
|
||
${p.autor_founder_number ? `<span style="font-size:10px;font-weight:700;background:#7c3aed;color:#fff;padding:1px 5px;border-radius:4px">Gründer #${p.autor_founder_number}</span>` : ''}
|
||
<span class="forum-post-date">${_fmtDate(p.created_at)}</span>
|
||
</div>
|
||
<div class="forum-post-body">
|
||
<div class="forum-post-text">${UI.escape(p.text)}</div>
|
||
${fotoHtml}
|
||
</div>
|
||
<div class="forum-post-actions">
|
||
<button class="${likeClass} forum-post-like" data-post-id="${p.id}">
|
||
${UI.icon('heart')} <span class="forum-post-like-count">${p.likes || 0}</span>
|
||
</button>
|
||
${(!isOwn && uid) ? `<button class="forum-icon-btn forum-post-report" data-post-id="${p.id}" title="Melden">${UI.icon('flag')}</button>` : ''}
|
||
<div style="margin-left:auto;display:flex;gap:4px">
|
||
${isOwn ? `<button class="forum-icon-btn forum-post-edit" data-post-id="${p.id}" data-text="${UI.escape(p.text || '')}" title="Bearbeiten">${UI.icon('pencil-simple')}</button>` : ''}
|
||
${canDelete ? `<button class="forum-icon-btn forum-icon-btn--danger forum-post-delete" data-post-id="${p.id}" title="Löschen">${UI.icon('trash')}</button>` : ''}
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// Post-Aktionen binden
|
||
// ----------------------------------------------------------
|
||
function _bindPostActions(container, threadId, uid, isMod) {
|
||
// Like
|
||
container.querySelectorAll('.forum-post-like:not([data-bound])').forEach(btn => {
|
||
btn.dataset.bound = '1';
|
||
const postId = parseInt(btn.dataset.postId);
|
||
btn.addEventListener('click', async () => {
|
||
if (!uid) { UI.toast.info('Bitte erst anmelden.'); return; }
|
||
try {
|
||
const res = await API.forum.like('post', postId);
|
||
btn.classList.toggle('active', res.liked);
|
||
const countEl = btn.querySelector('.forum-post-like-count');
|
||
if (countEl) countEl.textContent = res.count;
|
||
} catch (err) { UI.toast.error(err.message); }
|
||
});
|
||
// Klick auf die Zahl → Liker-Liste
|
||
const countEl = btn.querySelector('.forum-post-like-count');
|
||
if (countEl) {
|
||
countEl.style.cursor = 'pointer';
|
||
countEl.title = 'Wer hat geliked?';
|
||
countEl.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
if (parseInt(countEl.textContent) > 0) _showLikers('post', postId);
|
||
});
|
||
}
|
||
});
|
||
|
||
// Report
|
||
container.querySelectorAll('.forum-post-report:not([data-bound])').forEach(btn => {
|
||
btn.dataset.bound = '1';
|
||
btn.addEventListener('click', () => {
|
||
_showReportForm('post', parseInt(btn.dataset.postId));
|
||
});
|
||
});
|
||
|
||
// Edit (owner)
|
||
container.querySelectorAll('.forum-post-edit:not([data-bound])').forEach(btn => {
|
||
btn.dataset.bound = '1';
|
||
btn.addEventListener('click', () => {
|
||
const postId = parseInt(btn.dataset.postId);
|
||
const currentText = btn.dataset.text || '';
|
||
_showEditPostModal(postId, currentText, container, threadId, uid, isMod);
|
||
});
|
||
});
|
||
|
||
// Delete
|
||
container.querySelectorAll('.forum-post-delete:not([data-bound])').forEach(btn => {
|
||
btn.dataset.bound = '1';
|
||
btn.addEventListener('click', async () => {
|
||
const postId = parseInt(btn.dataset.postId);
|
||
const postEl = container.querySelector(`[data-post-id="${postId}"]`);
|
||
const ok = await UI.modal.confirm({
|
||
title: 'Antwort löschen?',
|
||
message: 'Dieser Beitrag wird entfernt.',
|
||
confirmText: 'Löschen', danger: true,
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
await API.forum.deletePost(postId);
|
||
if (postEl) {
|
||
postEl.innerHTML = '<em class="text-muted">Beitrag wurde entfernt</em>';
|
||
postEl.className = 'forum-post forum-post--deleted';
|
||
}
|
||
const idx = _threads.findIndex(t => t.id === threadId);
|
||
if (idx !== -1 && _threads[idx].antworten > 0) _threads[idx].antworten--;
|
||
UI.toast.success('Beitrag gelöscht.');
|
||
} catch (err) { UI.toast.error(err.message); }
|
||
});
|
||
});
|
||
|
||
// Foto-Fullscreen
|
||
container.querySelectorAll('.forum-foto-img:not([data-bound])').forEach(img => {
|
||
img.dataset.bound = '1';
|
||
img.addEventListener('click', () => _showLightbox(img.dataset.src || img.src));
|
||
});
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// Liker-Liste — wer hat geliked?
|
||
// ----------------------------------------------------------
|
||
async function _showLikers(targetType, targetId) {
|
||
try {
|
||
const likers = await API.forum.likers(targetType, targetId);
|
||
if (!likers.length) { UI.toast.info('Noch keine Likes.'); return; }
|
||
const rows = likers.map(l => `
|
||
<div style="display:flex;align-items:center;gap:var(--space-2);padding:var(--space-2) 0;border-bottom:1px solid var(--c-border-light)">
|
||
<div class="forum-avatar forum-avatar--sm">${UI.escape(_initial(l.name))}</div>
|
||
<span style="font-size:0.9rem">${UI.escape(l.name || 'Unbekannt')}</span>
|
||
${l.founder_number ? `<span style="font-size:10px;font-weight:700;background:#7c3aed;color:#fff;padding:1px 5px;border-radius:4px;margin-left:auto">Gründer #${l.founder_number}</span>` : ''}
|
||
</div>`).join('');
|
||
UI.modal.open({
|
||
title: `${UI.icon('heart')} ${likers.length} ${likers.length === 1 ? 'Like' : 'Likes'}`,
|
||
body: `<div style="max-height:50vh;overflow-y:auto">${rows}</div>`,
|
||
footer: `<button type="button" class="btn btn-secondary w-full" id="likers-close">Schließen</button>`,
|
||
});
|
||
document.getElementById('likers-close')?.addEventListener('click', UI.modal.close);
|
||
} catch (err) { UI.toast.error(err.message); }
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// Report-Formular
|
||
// ----------------------------------------------------------
|
||
function _showReportForm(targetType, targetId) {
|
||
const body = `
|
||
<form id="forum-report-form">
|
||
<div class="form-group">
|
||
<label class="form-label">Grund der Meldung</label>
|
||
<select class="form-control" name="grund">
|
||
<option value="spam">Spam</option>
|
||
<option value="beleidigung">Beleidigung / Hassrede</option>
|
||
<option value="falschinfo">Falsche Informationen</option>
|
||
<option value="sonstiges">Sonstiges</option>
|
||
</select>
|
||
</div>
|
||
</form>`;
|
||
const footer = `
|
||
<button type="button" class="btn btn-secondary flex-1" id="rep-cancel">Abbrechen</button>
|
||
<button type="submit" form="forum-report-form" class="btn btn-danger flex-1">Melden</button>`;
|
||
|
||
UI.modal.open({ title: `${UI.icon('flag')} Inhalt melden`, body, footer });
|
||
document.getElementById('rep-cancel')?.addEventListener('click', UI.modal.close);
|
||
|
||
document.getElementById('forum-report-form')?.addEventListener('submit', async e => {
|
||
e.preventDefault();
|
||
const btn = document.querySelector('[form="forum-report-form"][type="submit"]');
|
||
const fd = UI.formData(e.target);
|
||
await UI.asyncButton(btn, async () => {
|
||
await API.forum.report(targetType, targetId, fd.grund);
|
||
UI.modal.close();
|
||
UI.toast.success('Gemeldet. Danke!');
|
||
});
|
||
});
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// Neues Thema
|
||
// ----------------------------------------------------------
|
||
// Regeln & Netiquette
|
||
// ----------------------------------------------------------
|
||
function _showRules() {
|
||
UI.modal.open({
|
||
title: `${UI.icon('info')} Regeln & Netiquette`,
|
||
body: `
|
||
<div style="display:flex;flex-direction:column;gap:var(--space-4)">
|
||
|
||
<div>
|
||
<p style="color:var(--c-text-secondary);font-size:var(--text-sm);margin-bottom:var(--space-3)">
|
||
Das Ban-Yaro-Forum ist ein Ort für Hundehalter — freundlich, hilfsbereit und respektvoll.
|
||
Bitte halte diese Grundregeln ein, damit es für alle schön bleibt.
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<div style="font-weight:var(--weight-semibold);margin-bottom:var(--space-2);display:flex;align-items:center;gap:var(--space-2)">
|
||
${UI.icon('chat-circle-dots')} Ton & Umgang
|
||
</div>
|
||
<ul style="list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:var(--space-1);font-size:var(--text-sm);color:var(--c-text-secondary)">
|
||
<li>${UI.icon('check')} Freundlich und respektvoll bleiben — auch bei verschiedenen Meinungen</li>
|
||
<li>${UI.icon('check')} Konstruktive Kritik statt persönliche Angriffe</li>
|
||
<li>${UI.icon('check')} Andere Haltungsmethoden tolerieren — solange der Hund nicht leidet</li>
|
||
<li>${UI.icon('prohibit')} Keine Beleidigungen, Drohungen oder Diskriminierung</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div>
|
||
<div style="font-weight:var(--weight-semibold);margin-bottom:var(--space-2);display:flex;align-items:center;gap:var(--space-2)">
|
||
${UI.icon('files')} Inhalte
|
||
</div>
|
||
<ul style="list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:var(--space-1);font-size:var(--text-sm);color:var(--c-text-secondary)">
|
||
<li>${UI.icon('check')} Thema passend zur gewählten Kategorie</li>
|
||
<li>${UI.icon('check')} Aussagekräftiger Titel — kein "Hilfe!!!" ohne Kontext</li>
|
||
<li>${UI.icon('prohibit')} Keine Werbung, Spam oder Affiliate-Links</li>
|
||
<li>${UI.icon('prohibit')} Keine Doppelposts — bestehenden Thread suchen bevor du neu erstellst</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div>
|
||
<div style="font-weight:var(--weight-semibold);margin-bottom:var(--space-2);display:flex;align-items:center;gap:var(--space-2)">
|
||
${UI.icon('syringe')} Gesundheit & Notfälle
|
||
</div>
|
||
<ul style="list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:var(--space-1);font-size:var(--text-sm);color:var(--c-text-secondary)">
|
||
<li>${UI.icon('check')} Erfahrungen teilen ist wertvoll — bitte immer den Tierarzt empfehlen</li>
|
||
<li>${UI.icon('prohibit')} Keine Diagnosen oder Medikamentendosierungen für fremde Hunde</li>
|
||
<li>${UI.icon('warning-circle')} Bei Notfällen: direkt zum Tierarzt — nicht erst im Forum fragen</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div>
|
||
<div style="font-weight:var(--weight-semibold);margin-bottom:var(--space-2);display:flex;align-items:center;gap:var(--space-2)">
|
||
${UI.icon('flag')} Moderation
|
||
</div>
|
||
<ul style="list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:var(--space-1);font-size:var(--text-sm);color:var(--c-text-secondary)">
|
||
<li>${UI.icon('check')} Regelverstoß? Melde-Funktion nutzen statt selbst zu reagieren</li>
|
||
<li>${UI.icon('check')} Moderatoren können Beiträge bearbeiten, verstecken oder löschen</li>
|
||
<li>${UI.icon('check')} Bei Unklarheiten freundlich nachfragen</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<p style="font-size:var(--text-xs);color:var(--c-text-muted);border-top:1px solid var(--c-border-light);padding-top:var(--space-3);margin:0">
|
||
Wer wiederholt gegen die Regeln verstößt, kann vorübergehend gesperrt werden.
|
||
Das Ziel ist ein freundliches Forum — nicht Kontrolle um der Kontrolle willen. 🐾
|
||
</p>
|
||
|
||
</div>`,
|
||
footer: `<button class="btn btn-primary flex-1" data-modal-close>Verstanden</button>`,
|
||
});
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
function _showCreateForm() {
|
||
const katOptions = KATEGORIEN.filter(k => k.key !== 'alle').map(k =>
|
||
`<option value="${k.key}" ${k.key === _aktivKat ? 'selected' : ''}>${UI.escape(k.label)}</option>`
|
||
).join('');
|
||
|
||
const body = `
|
||
<form id="forum-thread-form" autocomplete="off">
|
||
<div class="form-group">
|
||
<label class="form-label">Kategorie</label>
|
||
<select class="form-control" name="kategorie">${katOptions}</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Titel *</label>
|
||
<input class="form-control" type="text" name="titel"
|
||
placeholder="Worum geht es?" required maxlength="200">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Text * (min. 20 Zeichen)</label>
|
||
<textarea class="form-control" name="text" rows="5"
|
||
placeholder="Beschreibe dein Thema ausführlich…" required></textarea>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Standort <span class="text-secondary">(optional)</span></label>
|
||
<div id="forum-location-picker"></div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Fotos / Dateien (max. 5)</label>
|
||
<div class="forum-upload-area">
|
||
<label class="btn btn-secondary btn-sm" for="forum-thread-files">${UI.icon('image')} Fotos / Video / PDF</label>
|
||
<input type="file" id="forum-thread-files" accept="image/*,video/*,application/pdf" multiple class="hidden">
|
||
</div>
|
||
<div id="forum-thread-previews" class="forum-upload-previews mt-2"></div>
|
||
</div>
|
||
</form>
|
||
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-3)">
|
||
${UI.icon('info')} Bitte die
|
||
<button type="button" style="background:none;border:none;padding:0;color:var(--c-primary);cursor:pointer;font-size:inherit;text-decoration:underline" id="ff-rules-link">Regeln & Netiquette</button>
|
||
beachten.
|
||
</p>`;
|
||
|
||
const footer = `
|
||
<button type="button" class="btn btn-secondary flex-1" id="ff-cancel">Abbrechen</button>
|
||
<button type="submit" form="forum-thread-form" class="btn btn-primary flex-1">${UI.icon('chat-circle-dots')} Erstellen</button>`;
|
||
|
||
UI.modal.open({ title: '+ Neues Thema', body, footer });
|
||
|
||
let _picker = null;
|
||
setTimeout(() => {
|
||
_picker = UI.locationPicker({ containerId: 'forum-location-picker' });
|
||
}, 50);
|
||
|
||
document.getElementById('ff-cancel')?.addEventListener('click', UI.modal.close);
|
||
document.getElementById('ff-rules-link')?.addEventListener('click', _showRules);
|
||
|
||
// Foto-Vorschau — eigenes Array damit iOS-Mehrfachauswahl akkumuliert
|
||
let _threadFiles = [];
|
||
|
||
const _renderThreadPreviews = () => {
|
||
const previews = document.getElementById('forum-thread-previews');
|
||
if (!previews) return;
|
||
previews.innerHTML = '';
|
||
_threadFiles.forEach((file, i) => {
|
||
const wrap = document.createElement('div');
|
||
wrap.style.cssText = 'position:relative;display:inline-block';
|
||
let thumb;
|
||
if (file.type === 'application/pdf' || file.name.endsWith('.pdf')) {
|
||
thumb = document.createElement('div');
|
||
thumb.className = 'forum-upload-thumb';
|
||
thumb.style.cssText = 'display:flex;align-items:center;justify-content:center;background:var(--c-surface-2);font-size:11px;color:var(--c-text-secondary);text-align:center;padding:4px';
|
||
thumb.textContent = '📄 PDF';
|
||
} else if (file.type.startsWith('video/')) {
|
||
thumb = document.createElement('video');
|
||
thumb.src = URL.createObjectURL(file);
|
||
thumb.className = 'forum-upload-thumb';
|
||
thumb.muted = true;
|
||
} else {
|
||
thumb = document.createElement('img');
|
||
thumb.src = URL.createObjectURL(file);
|
||
thumb.className = 'forum-upload-thumb';
|
||
}
|
||
const del = document.createElement('button');
|
||
del.type = 'button';
|
||
del.textContent = '×';
|
||
del.style.cssText = 'position:absolute;top:-4px;right:-4px;width:18px;height:18px;border-radius:50%;' +
|
||
'background:var(--c-danger);color:#fff;border:none;font-size:12px;line-height:1;cursor:pointer;' +
|
||
'display:flex;align-items:center;justify-content:center;padding:0';
|
||
del.addEventListener('click', () => { _threadFiles.splice(i, 1); _renderThreadPreviews(); });
|
||
wrap.appendChild(thumb);
|
||
wrap.appendChild(del);
|
||
previews.appendChild(wrap);
|
||
});
|
||
};
|
||
|
||
document.getElementById('forum-thread-files')?.addEventListener('change', e => {
|
||
const neu = Array.from(e.target.files || []);
|
||
neu.forEach(f => {
|
||
if (_threadFiles.length < 5) _threadFiles.push(f);
|
||
});
|
||
e.target.value = ''; // reset damit dieselbe Datei nochmal wählbar ist
|
||
_renderThreadPreviews();
|
||
});
|
||
|
||
document.getElementById('forum-thread-form')?.addEventListener('submit', async e => {
|
||
e.preventDefault();
|
||
const btn = document.querySelector('[form="forum-thread-form"][type="submit"]');
|
||
const fd = UI.formData(e.target);
|
||
|
||
if ((fd.text || '').trim().length < 20) {
|
||
UI.toast.warning('Text muss mindestens 20 Zeichen lang sein.');
|
||
return;
|
||
}
|
||
|
||
await UI.asyncButton(btn, async () => {
|
||
const loc = _picker ? _picker.getValue() : { lat: null, lon: null, name: null };
|
||
|
||
const created = await API.forum.create({
|
||
kategorie: fd.kategorie,
|
||
titel: (fd.titel || '').trim(),
|
||
text: (fd.text || '').trim(),
|
||
thread_lat: loc.lat ?? null,
|
||
thread_lon: loc.lon ?? null,
|
||
thread_ort: loc.name ?? null,
|
||
client_time: API.clientNow(),
|
||
});
|
||
|
||
// Fotos hochladen
|
||
for (const file of _threadFiles.slice(0, 5)) {
|
||
try {
|
||
await API.forum.uploadThreadFoto(created.id, file);
|
||
} catch (e) { /* ignorieren */ }
|
||
}
|
||
|
||
_threads.unshift({
|
||
...created,
|
||
text_preview: created.text?.slice(0, 120) || '',
|
||
foto_preview: null,
|
||
});
|
||
UI.modal.close();
|
||
_renderList();
|
||
UI.toast.success('Beitrag erstellt!');
|
||
document.getElementById('forum-thread-list')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
});
|
||
});
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// Mitgliederkarte
|
||
// ----------------------------------------------------------
|
||
async function _renderMembersMap() {
|
||
const el = document.getElementById('forum-main');
|
||
if (!el) return;
|
||
|
||
el.innerHTML = `
|
||
<div class="forum-members-section">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-3)">
|
||
<strong>Mitglieder auf der Karte</strong>
|
||
${_appState.user ? `
|
||
<label class="forum-location-toggle">
|
||
<input type="checkbox" id="forum-loc-toggle" ${_appState.user.forum_show_location ? 'checked' : ''}>
|
||
<span>Meinen (ungefähren) Standort teilen</span>
|
||
</label>` : ''}
|
||
</div>
|
||
<div id="forum-map" class="forum-map-container"></div>
|
||
</div>`;
|
||
|
||
// Location-Toggle
|
||
document.getElementById('forum-loc-toggle')?.addEventListener('change', async e => {
|
||
const show = e.target.checked;
|
||
if (show) {
|
||
try {
|
||
const pos = await API.getLocation();
|
||
// Ortsmitte via Nominatim: erst Ort (zoom=10), dann Nominatim-Suche nach Ortsname
|
||
let lat = pos.lat, lon = pos.lon;
|
||
try {
|
||
const rev = await fetch(
|
||
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json&zoom=10&addressdetails=1&accept-language=de`,
|
||
{ cache: 'no-store' }
|
||
);
|
||
const d = await rev.json();
|
||
const a = d.address || {};
|
||
const ort = a.city || a.town || a.village || a.municipality || '';
|
||
if (ort) {
|
||
// Ortsname vorwärts-geocodieren um echte Ortsmitte zu bekommen
|
||
const fwd = await fetch(
|
||
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(ort)}&format=json&limit=1&accept-language=de`,
|
||
{ cache: 'no-store' }
|
||
);
|
||
const results = await fwd.json();
|
||
if (results[0]?.lat && results[0]?.lon) {
|
||
lat = parseFloat(results[0].lat);
|
||
lon = parseFloat(results[0].lon);
|
||
}
|
||
}
|
||
} catch {}
|
||
await API.forum.setLocation(lat, lon, true);
|
||
UI.toast.success('Ortsmitte geteilt — dein genauer Standort bleibt privat.');
|
||
_loadMembersOnMap();
|
||
} catch (err) {
|
||
e.target.checked = false;
|
||
UI.toast.error('Standort konnte nicht ermittelt werden.');
|
||
}
|
||
} else {
|
||
try {
|
||
await API.forum.setLocation(null, null, false);
|
||
UI.toast.success('Standort versteckt.');
|
||
_loadMembersOnMap();
|
||
} catch (err) { UI.toast.error(err.message); }
|
||
}
|
||
});
|
||
|
||
const mapEl = document.getElementById('forum-map');
|
||
if (!mapEl) return;
|
||
|
||
// GL über die Facade (gleicher Style wie die zentrale Karte), Fallback Leaflet.
|
||
_map = await UI.map.create(mapEl, { center: [51.0, 10.0], zoom: 6, zoomControl: true, attributionControl: false });
|
||
|
||
_loadMembersOnMap();
|
||
}
|
||
|
||
async function _loadMembersOnMap() {
|
||
if (!_map) return;
|
||
try {
|
||
const members = await API.forum.membersMap();
|
||
|
||
// Alte Gruppe sauber entfernen
|
||
if (_clusterGroup) { try { _map.removeLayer(_clusterGroup); } catch (e) {} _clusterGroup = null; }
|
||
|
||
_clusterGroup = UI.map.clusterGroup({ maxClusterRadius: 60 });
|
||
members.forEach(m => {
|
||
const html = `<div style="width:32px;height:32px;border-radius:50%;
|
||
background:var(--c-primary);color:#fff;font-size:13px;font-weight:700;
|
||
display:flex;align-items:center;justify-content:center;
|
||
box-shadow:0 2px 5px rgba(0,0,0,0.35);
|
||
border:2px solid rgba(255,255,255,0.8)">${UI.escape((m.vorname||'?')[0].toUpperCase())}</div>`;
|
||
_clusterGroup.addLayer(
|
||
UI.map.svgMarker(m.lat, m.lon, html, { size: 32, anchorY: 16 })
|
||
.bindPopup(`<strong>${UI.escape(m.vorname || '?')}</strong>`)
|
||
);
|
||
});
|
||
_map.addLayer(_clusterGroup);
|
||
} catch (err) {
|
||
console.error('Mitgliederkarte Fehler:', err);
|
||
}
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// Moderations-Panel
|
||
// ----------------------------------------------------------
|
||
async function _showModPanel() {
|
||
let reports;
|
||
try {
|
||
reports = await API.forum.reports();
|
||
} catch (err) {
|
||
UI.toast.error(err.message);
|
||
return;
|
||
}
|
||
|
||
const body = reports.length
|
||
? `<div class="forum-mod-reports">
|
||
${reports.map(r => `
|
||
<div class="forum-mod-report-item" data-id="${r.id}">
|
||
<div class="text-sm">
|
||
<strong>${UI.escape(r.target_type)} #${r.target_id}</strong>
|
||
— ${UI.escape(r.grund)}
|
||
</div>
|
||
<div class="text-xs-muted">
|
||
von ${UI.escape(r.melder_name || '?')} · ${_fmtDate(r.created_at)}
|
||
</div>
|
||
<button class="btn btn-sm btn-secondary forum-resolve-btn mt-2" data-id="${r.id}">
|
||
${UI.icon('check')} Erledigt
|
||
</button>
|
||
</div>`).join('')}
|
||
</div>`
|
||
: `<p style="color:var(--c-text-muted);text-align:center;padding:var(--space-6)">Keine offenen Berichte.</p>`;
|
||
|
||
const footer = `<button type="button" class="btn btn-primary flex-1" id="mod-close">Schließen</button>`;
|
||
UI.modal.open({ title: `${UI.icon('scales')} Moderationsberichte`, body, footer });
|
||
document.getElementById('mod-close')?.addEventListener('click', UI.modal.close);
|
||
|
||
document.querySelectorAll('.forum-resolve-btn').forEach(btn => {
|
||
btn.addEventListener('click', async () => {
|
||
const id = parseInt(btn.dataset.id);
|
||
try {
|
||
await API.forum.resolveReport(id);
|
||
btn.closest('.forum-mod-report-item')?.remove();
|
||
UI.toast.success('Erledigt.');
|
||
} catch (err) { UI.toast.error(err.message); }
|
||
});
|
||
});
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// Post bearbeiten (Besitzer)
|
||
// ----------------------------------------------------------
|
||
function _showEditPostModal(postId, currentText, container, threadId, uid, isMod) {
|
||
const id = 'forum-edit-post-form';
|
||
UI.modal.open({
|
||
title: 'Antwort bearbeiten',
|
||
body: `<form id="${id}">
|
||
<div class="form-group">
|
||
<textarea class="form-control" name="text" rows="5" required>${UI.escape(currentText)}</textarea>
|
||
</div>
|
||
</form>`,
|
||
footer: `
|
||
<button class="btn btn-ghost flex-1" data-modal-close>Abbrechen</button>
|
||
<button type="submit" form="${id}" class="btn btn-primary flex-1">${UI.icon('floppy-disk')} Speichern</button>`,
|
||
});
|
||
document.getElementById(id)?.addEventListener('submit', async e => {
|
||
e.preventDefault();
|
||
const text = new FormData(e.target).get('text')?.trim();
|
||
if (!text) return;
|
||
const btn = document.querySelector(`[form="${id}"][type="submit"]`);
|
||
await UI.asyncButton(btn, async () => {
|
||
await API.forum.updatePost(postId, { text });
|
||
UI.modal.close();
|
||
// Post-Text im DOM aktualisieren
|
||
if (container) {
|
||
const postEl = container.querySelector(`[data-post-id="${postId}"]`);
|
||
const textEl = postEl?.querySelector('.forum-post-text');
|
||
if (textEl) textEl.textContent = text;
|
||
// data-text auf Edit-Button aktualisieren
|
||
const editBtn = postEl?.querySelector('.forum-post-edit');
|
||
if (editBtn) editBtn.dataset.text = text;
|
||
}
|
||
UI.toast.success('Antwort aktualisiert.');
|
||
});
|
||
});
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// Thread bearbeiten (Besitzer)
|
||
// ----------------------------------------------------------
|
||
function _showEditThreadModal(thread) {
|
||
const id = 'forum-edit-thread-form';
|
||
UI.modal.open({
|
||
title: 'Beitrag bearbeiten',
|
||
body: `<form id="${id}">
|
||
<div class="form-group">
|
||
<label class="form-label">Titel</label>
|
||
<input class="form-control" name="titel" value="${UI.escape(thread.titel || '')}" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Text</label>
|
||
<textarea class="form-control" name="text" rows="5">${UI.escape(thread.text || '')}</textarea>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Standort <span class="text-secondary">(optional)</span></label>
|
||
<div id="forum-edit-location-picker"></div>
|
||
</div>
|
||
</form>`,
|
||
footer: `
|
||
<button class="btn btn-ghost flex-1" data-modal-close>Abbrechen</button>
|
||
<button type="submit" form="${id}" class="btn btn-primary flex-1">${UI.icon('floppy-disk')} Speichern</button>`,
|
||
});
|
||
|
||
let _picker = null;
|
||
setTimeout(() => {
|
||
_picker = UI.locationPicker({ containerId: 'forum-edit-location-picker' });
|
||
if (thread.thread_lat && thread.thread_lon) {
|
||
_picker.setValue(thread.thread_lat, thread.thread_lon, thread.thread_ort || null);
|
||
}
|
||
}, 50);
|
||
|
||
document.getElementById(id)?.addEventListener('submit', async e => {
|
||
e.preventDefault();
|
||
const fd = new FormData(e.target);
|
||
const loc = _picker ? _picker.getValue() : { lat: null, lon: null, name: null };
|
||
const btn = document.querySelector(`[form="${id}"][type="submit"]`);
|
||
await UI.asyncButton(btn, async () => {
|
||
await API.forum.updateThread(thread.id, {
|
||
titel: fd.get('titel'),
|
||
text: fd.get('text'),
|
||
thread_lat: loc.lat ?? null,
|
||
thread_lon: loc.lon ?? null,
|
||
thread_ort: loc.name ?? null,
|
||
});
|
||
UI.modal.close();
|
||
_loadThreads(true);
|
||
UI.toast.success('Beitrag aktualisiert.');
|
||
});
|
||
});
|
||
}
|
||
|
||
function openNew() {
|
||
if (!_appState?.user) { UI.toast.info('Bitte erst anmelden.'); return; }
|
||
_showCreateForm();
|
||
}
|
||
|
||
function _showLightbox(src) {
|
||
const lb = document.createElement('div');
|
||
lb.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:9999;display:flex;align-items:center;justify-content:center;cursor:zoom-out';
|
||
lb.innerHTML = `<img src="${UI.escape(src)}" style="max-width:100%;max-height:100%;object-fit:contain;touch-action:pinch-zoom">
|
||
<button style="position:absolute;top:16px;right:16px;background:rgba(255,255,255,.2);border:none;border-radius:50%;width:44px;height:44px;color:#fff;font-size:22px;cursor:pointer;display:flex;align-items:center;justify-content:center">✕</button>`;
|
||
lb.addEventListener('click', () => lb.remove());
|
||
document.body.appendChild(lb);
|
||
}
|
||
|
||
// Karte beim Verlassen freigeben (WebGL-Kontext-Leak vermeiden).
|
||
function destroy() {
|
||
try { _clusterGroup && _clusterGroup.remove && _clusterGroup.remove(); } catch (e) {}
|
||
try { _map && _map.remove && _map.remove(); } catch (e) {}
|
||
_map = null; _clusterGroup = null;
|
||
}
|
||
|
||
return { init, refresh, onDogChange, openNew, openThread: _openThread, destroy };
|
||
|
||
})();
|