PHASE 1 — Sofort-Cleanup ohne Risiko: - Neue Datei utilities.css mit ~25 Klassen für häufige Kombinationen: * text-xs-muted, text-xs-secondary, text-sm-muted, text-sm-secondary * flex-gap-2/3, flex-col-gap-2/3/4, flex-center-gap-1/2/3 * flex-between, flex-1-min, mb-1/3, mt-1/3 * icon-xs/sm/md/lg, label-block, caption - index.html bindet utilities.css ein - mb-3/mt-3 ergänzt (waren in design-system.css unvollständig) PHASE 2 — .by-tab Modifier für Vereinheitlichung: - .by-tabs.grid (mit --tab-cols Variable für Admin/Health/etc.) - .by-tabs.sticky (Desktop vertikale Tabs für Admin) - .by-tabs.wrap (Zuchthunde, flex-wrap statt scroll) - .by-tabs.separated (Sitting, mit eigenem Hintergrund + Border) PHASE 3 — Inline-Style → Klassen-Migration (Python-Script): - 948 Inline-Styles entfernt (5101 → 4153, -18%) - 962 Migrationen über 47 Page-Dateien - Top-Treffer: admin.js (180), health.js (67), dog-profile.js (67), litters.js (62), settings.js (61), zuchthunde.js (51) - Patterns: text-muted, text-secondary, text-danger, text-xs-muted, text-sm-muted, grid-2 (Duplikat-Bug behoben!), flex-col-gap-3, p-3/4, mb-2/3/4, hidden, w-full, flex-1, ... - Bewahrt bestehende class-Attribute (mergt korrekt) Alle 19 Tests grün. Kein visueller Diff erwartet (gleiche Property-Werte).
547 lines
23 KiB
JavaScript
547 lines
23 KiB
JavaScript
/* ============================================================
|
|
BAN YARO — Moderations-Panel
|
|
Nur für Moderatoren und Admins.
|
|
============================================================ */
|
|
|
|
window.Page_moderation = (() => {
|
|
|
|
let _container = null;
|
|
let _appState = null;
|
|
let _tab = 'uebersicht';
|
|
|
|
const TABS = [
|
|
{ id: 'uebersicht', label: 'Übersicht', icon: 'house-line' },
|
|
{ id: 'fotos', label: 'Fotos', icon: 'image' },
|
|
{ id: 'user', label: 'User', icon: 'users' },
|
|
{ id: 'forum', label: 'Forum', icon: 'chat-circle-dots' },
|
|
{ id: 'poi-edits', label: 'POI-Edits', icon: 'clock' },
|
|
];
|
|
|
|
// ------------------------------------------------------------------
|
|
async function init(container, appState) {
|
|
_container = container;
|
|
_appState = appState;
|
|
|
|
const u = appState.user;
|
|
const isMod = u?.rolle === 'admin' || u?.rolle === 'moderator' || u?.is_moderator;
|
|
if (!isMod) {
|
|
container.innerHTML = _emptyState('shield', 'Kein Zugriff',
|
|
'Dieser Bereich ist nur für Moderatoren und Admins.');
|
|
return;
|
|
}
|
|
|
|
_render();
|
|
}
|
|
|
|
function refresh() { _renderTab(); }
|
|
function onDogChange() {}
|
|
|
|
// ------------------------------------------------------------------
|
|
// SHELL
|
|
// ------------------------------------------------------------------
|
|
function _render() {
|
|
_container.innerHTML = `
|
|
<div class="by-tabs adm-tabs" id="mod-tabs">
|
|
${TABS.map(t => `
|
|
<button class="by-tab${t.id === _tab ? ' active' : ''}" data-tab="${t.id}">
|
|
${UI.icon(t.icon)} ${t.label}
|
|
</button>
|
|
`).join('')}
|
|
</div>
|
|
<div id="mod-content"></div>
|
|
`;
|
|
|
|
_container.querySelector('#mod-tabs')
|
|
?.style.setProperty('--adm-tab-cols', Math.ceil(TABS.length / 2));
|
|
|
|
_container.querySelectorAll('#mod-tabs .by-tab').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
_tab = btn.dataset.tab;
|
|
_container.querySelectorAll('#mod-tabs .by-tab').forEach(b =>
|
|
b.classList.toggle('active', b.dataset.tab === _tab)
|
|
);
|
|
_renderTab();
|
|
});
|
|
});
|
|
|
|
_renderTab();
|
|
}
|
|
|
|
async function _renderTab() {
|
|
const el = _container.querySelector('#mod-content');
|
|
if (!el) return;
|
|
el.innerHTML = `<div style="padding:var(--space-6);text-align:center;
|
|
color:var(--c-text-muted)">Lade…</div>`;
|
|
try {
|
|
switch (_tab) {
|
|
case 'uebersicht': await _renderStats(el); break;
|
|
case 'fotos': await _renderFotos(el); break;
|
|
case 'user': await _renderUsers(el); break;
|
|
case 'forum': await _renderForum(el); break;
|
|
case 'poi-edits': await _renderPoiEdits(el); break;
|
|
}
|
|
} catch (e) {
|
|
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.');
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// TAB: ÜBERSICHT
|
|
// ------------------------------------------------------------------
|
|
function _switchTab(tabId) {
|
|
_tab = tabId;
|
|
_container.querySelectorAll('#mod-tabs .by-tab').forEach(b =>
|
|
b.classList.toggle('active', b.dataset.tab === _tab)
|
|
);
|
|
_renderTab();
|
|
}
|
|
|
|
async function _renderStats(el) {
|
|
const s = await API.get('/moderation/stats');
|
|
el.innerHTML = `
|
|
<div class="adm-stats-grid">
|
|
${_statCard('warning', 'Offene Meldungen', s.open_reports, s.open_reports > 0 ? 'var(--c-danger)' : 'var(--c-text-muted)', 'forum')}
|
|
${_statCard('image', 'Fotos ausstehend', s.pending_fotos, s.pending_fotos > 0 ? 'var(--c-warning)' : 'var(--c-text-muted)', 'fotos')}
|
|
${_statCard('skull', 'Gesperrte User', s.banned_users, s.banned_users > 0 ? '#f59e0b' : 'var(--c-text-muted)', 'user')}
|
|
${_statCard('storefront','Züchter ausstehend',s.pending_zuchter, s.pending_zuchter > 0 ? 'var(--c-warning)' : 'var(--c-text-muted)', 'user')}
|
|
${_statCard('clock', 'POI-Korrekturen', s.pending_poi_edits ?? 0,(s.pending_poi_edits ?? 0) > 0 ? 'var(--c-warning)' : 'var(--c-text-muted)', 'poi-edits')}
|
|
</div>
|
|
|
|
`;
|
|
el.querySelectorAll('.mod-stat-card[data-tab]').forEach(card => {
|
|
card.addEventListener('click', () => _switchTab(card.dataset.tab));
|
|
});
|
|
}
|
|
|
|
function _statCard(icon, label, value, color, tab) {
|
|
const clickable = tab ? `data-tab="${tab}" style="padding:var(--space-4);text-align:center;cursor:pointer;transition:box-shadow .15s,transform .15s" onmouseenter="this.style.boxShadow='var(--shadow-md)';this.style.transform='translateY(-2px)'" onmouseleave="this.style.boxShadow='';this.style.transform=''"` : `style="padding:var(--space-4);text-align:center"`;
|
|
return `
|
|
<div class="card mod-stat-card" ${clickable}>
|
|
<svg class="ph-icon" style="width:24px;height:24px;color:${color};
|
|
margin-bottom:var(--space-2)" aria-hidden="true">
|
|
<use href="/icons/phosphor.svg#${icon}"></use>
|
|
</svg>
|
|
<div style="font-size:var(--text-2xl);font-weight:var(--weight-bold);
|
|
color:var(--c-text)">${value ?? '—'}</div>
|
|
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
|
|
margin-top:2px">${label}</div>
|
|
${tab ? `<div style="font-size:10px;color:var(--c-primary);margin-top:var(--space-2);opacity:.7">${UI.icon('arrow-right')} öffnen</div>` : ''}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// TAB: FOTOS
|
|
// ------------------------------------------------------------------
|
|
async function _renderFotos(el) {
|
|
el.innerHTML = `
|
|
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
|
|
<button class="btn btn-ghost btn-sm" id="mod-fotos-refresh">
|
|
${UI.icon('arrows-clockwise')} Aktualisieren
|
|
</button>
|
|
</div>
|
|
<div id="mod-fotos-list">Lade…</div>
|
|
`;
|
|
el.querySelector('#mod-fotos-refresh').addEventListener('click', () =>
|
|
_loadFotos(el.querySelector('#mod-fotos-list'))
|
|
);
|
|
await _loadFotos(el.querySelector('#mod-fotos-list'));
|
|
}
|
|
|
|
async function _loadFotos(el) {
|
|
el.innerHTML = `<div style="padding:var(--space-4);text-align:center;
|
|
color:var(--c-text-muted)">Lade…</div>`;
|
|
const fotos = await API.get('/moderation/fotos');
|
|
if (!fotos.length) {
|
|
el.innerHTML = _emptyState('check-circle', 'Keine ausstehenden Fotos',
|
|
'Alle Foto-Einreichungen wurden bearbeitet.');
|
|
return;
|
|
}
|
|
el.innerHTML = `
|
|
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));
|
|
gap:var(--space-4)">
|
|
${fotos.map(f => `
|
|
<div class="card p-4" data-id="${f.id}">
|
|
<a href="#wiki?rasse=${_esc(f.rasse_slug)}" style="display:block;text-decoration:none">
|
|
<img src="${_esc(f.foto_url)}" alt=""
|
|
style="width:100%;height:140px;object-fit:cover;
|
|
border-radius:var(--radius-md);margin-bottom:var(--space-3)">
|
|
</a>
|
|
<div style="font-weight:var(--weight-semibold);font-size:var(--text-sm)">
|
|
${_esc(f.rasse_name || f.rasse_slug)}
|
|
</div>
|
|
<div style="font-size:var(--text-xs);color:var(--c-text-muted);
|
|
margin-bottom:var(--space-2)">
|
|
von ${_esc(f.user_name)}
|
|
</div>
|
|
<div class="mb-3">
|
|
${f.rights_confirmed
|
|
? `<span style="font-size:10px;font-weight:700;padding:2px 8px;border-radius:20px;
|
|
background:#dcfce7;color:#166534">✓ Bildrechte bestätigt</span>`
|
|
: `<span style="font-size:10px;font-weight:700;padding:2px 8px;border-radius:20px;
|
|
background:#fef9c3;color:#92400e">⚠ Keine Bestätigung</span>`}
|
|
</div>
|
|
${f.aktuell_foto ? `
|
|
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:4px">Aktuell:</div>
|
|
<img src="${_esc(f.aktuell_foto)}" alt="Aktuell"
|
|
style="width:100%;height:70px;object-fit:cover;
|
|
border-radius:var(--radius-sm);opacity:.5;
|
|
margin-bottom:var(--space-3)">
|
|
` : `<div style="font-size:var(--text-xs);color:var(--c-warning);
|
|
margin-bottom:var(--space-3)">Noch kein Foto vorhanden</div>`}
|
|
<div class="flex-gap-2">
|
|
<button class="btn btn-sm btn-primary mod-foto-approve"
|
|
data-id="${f.id}" class="flex-1"><svg class="ph-icon" style="width:14px;height:14px" aria-hidden="true"><use href="/icons/phosphor.svg#check"></use></svg> Freigeben</button>
|
|
<button class="btn btn-sm btn-ghost mod-foto-reject"
|
|
data-id="${f.id}" class="text-danger"><svg class="ph-icon" style="width:14px;height:14px" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg> Ablehnen</button>
|
|
</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
`;
|
|
|
|
el.querySelectorAll('.mod-foto-approve').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
btn.disabled = true;
|
|
btn.textContent = '…';
|
|
try {
|
|
await API.patch(`/moderation/fotos/${btn.dataset.id}`, { action: 'approve' });
|
|
UI.toast('Foto freigegeben.', 'success');
|
|
await _loadFotos(el);
|
|
} catch (e) {
|
|
if (e.status === 404) {
|
|
UI.toast('Bereits bearbeitet — Liste aktualisiert.', 'info');
|
|
await _loadFotos(el);
|
|
} else {
|
|
UI.toast(e.message, 'danger');
|
|
btn.disabled = false;
|
|
btn.textContent = '✓ Freigeben';
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
el.querySelectorAll('.mod-foto-reject').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
const reason = prompt('Ablehnungsgrund (optional, wird dem User angezeigt):');
|
|
if (reason === null) return;
|
|
btn.disabled = true;
|
|
try {
|
|
await API.patch(`/moderation/fotos/${btn.dataset.id}`, {
|
|
action: 'reject',
|
|
reject_reason: reason || 'Foto entspricht nicht den Anforderungen.'
|
|
});
|
|
UI.toast('Einreichung abgelehnt.', 'info');
|
|
await _loadFotos(el);
|
|
} catch (e) { UI.toast.error(e.message); btn.disabled = false; }
|
|
});
|
|
});
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// TAB: USER
|
|
// ------------------------------------------------------------------
|
|
async function _renderUsers(el) {
|
|
el.innerHTML = `
|
|
<div class="adm-filter-row">
|
|
<input id="mod-user-q" type="search" placeholder="Name oder E-Mail…"
|
|
class="adm-filter-input">
|
|
<label style="display:flex;align-items:center;gap:var(--space-2);
|
|
font-size:var(--text-sm);color:var(--c-text-secondary)">
|
|
<input type="checkbox" id="mod-only-banned"> Nur gesperrte
|
|
</label>
|
|
</div>
|
|
<div id="mod-user-list">Lade…</div>
|
|
`;
|
|
|
|
const load = async () => {
|
|
const q = el.querySelector('#mod-user-q').value;
|
|
const banned = el.querySelector('#mod-only-banned').checked ? 1 : 0;
|
|
const data = await API.get(
|
|
`/moderation/users?q=${encodeURIComponent(q)}&banned=${banned}`
|
|
);
|
|
_renderUserList(el.querySelector('#mod-user-list'), data.users, data.total, el);
|
|
};
|
|
|
|
let timer;
|
|
el.querySelector('#mod-user-q').addEventListener('input', () => {
|
|
clearTimeout(timer);
|
|
timer = setTimeout(load, 350);
|
|
});
|
|
el.querySelector('#mod-only-banned').addEventListener('change', load);
|
|
await load();
|
|
}
|
|
|
|
function _renderUserList(el, users, total, parentEl) {
|
|
// Moderatoren (non-admins) sehen keine Admin-User — serverseitig bereits
|
|
// gefiltert, aber zur Sicherheit auch clientseitig nochmal ausfiltern.
|
|
const isAdmin = _appState?.user?.rolle === 'admin';
|
|
const visible = isAdmin
|
|
? users
|
|
: users.filter(u => u.rolle !== 'admin' && !u.is_admin);
|
|
|
|
if (!visible.length) {
|
|
el.innerHTML = _emptyState('users', 'Keine Nutzer gefunden', '');
|
|
return;
|
|
}
|
|
el.innerHTML = `
|
|
<div style="margin-bottom:var(--space-2);font-size:var(--text-xs);
|
|
color:var(--c-text-muted)">${total} Nutzer gefunden</div>
|
|
<div class="flex-col-gap-2">
|
|
${visible.map(u => {
|
|
const isAdminUser = u.rolle === 'admin' || u.is_admin;
|
|
const canAction = isAdmin && !isAdminUser;
|
|
return `
|
|
<div class="card" style="padding:var(--space-3) var(--space-4);
|
|
${u.is_banned ? 'opacity:0.6;border-left:3px solid var(--c-danger)' : ''}">
|
|
<div style="display:flex;align-items:center;gap:var(--space-3)">
|
|
<div style="width:36px;height:36px;border-radius:50%;flex-shrink:0;
|
|
background:var(--c-surface-2);
|
|
display:flex;align-items:center;justify-content:center;
|
|
font-weight:var(--weight-bold);color:var(--c-text-secondary)">
|
|
${_esc(u.name[0].toUpperCase())}
|
|
</div>
|
|
<div class="flex-1-min">
|
|
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
|
color:var(--c-text)">
|
|
${_esc(u.name)}
|
|
${u.is_banned ? `<span style="font-size:10px;padding:1px 5px;
|
|
border-radius:3px;background:var(--c-danger);
|
|
color:#fff;margin-left:4px">GESPERRT</span>` : ''}
|
|
</div>
|
|
<div class="text-xs-muted">
|
|
${_esc(u.email)} ·
|
|
<span style="color:${
|
|
u.rolle === 'admin' ? 'var(--c-danger)'
|
|
: u.rolle === 'moderator' ? '#f59e0b'
|
|
: 'var(--c-text-muted)'}">
|
|
${_esc(u.rolle)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div style="flex-shrink:0">
|
|
${canAction
|
|
? (u.is_banned
|
|
? `<button class="btn btn-sm btn-ghost mod-unban"
|
|
data-uid="${u.id}" data-name="${_esc(u.name)}"
|
|
title="Sperre aufheben" class="text-success">
|
|
${UI.icon('lock-open')}
|
|
</button>`
|
|
: `<button class="btn btn-sm btn-ghost mod-ban"
|
|
data-uid="${u.id}" data-name="${_esc(u.name)}"
|
|
title="Sperren" class="text-danger">
|
|
${UI.icon('lock')}
|
|
</button>`)
|
|
: ''
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`}).join('')}
|
|
</div>
|
|
`;
|
|
|
|
el.querySelectorAll('.mod-ban').forEach(btn => {
|
|
btn.addEventListener('click', () => _banUser(btn.dataset.uid, btn.dataset.name, true, parentEl));
|
|
});
|
|
el.querySelectorAll('.mod-unban').forEach(btn => {
|
|
btn.addEventListener('click', () => _banUser(btn.dataset.uid, btn.dataset.name, false, parentEl));
|
|
});
|
|
}
|
|
|
|
async function _banUser(uid, name, ban, parentEl) {
|
|
if (ban) {
|
|
const reason = window.prompt(`${name} sperren — Grund (optional):`);
|
|
if (reason === null) return;
|
|
try {
|
|
await API.patch(`/moderation/users/${uid}`, {
|
|
is_banned: 1,
|
|
ban_reason: reason || 'Kein Grund angegeben.'
|
|
});
|
|
UI.toast.success(`${name} gesperrt.`);
|
|
_renderTab();
|
|
} catch (e) { UI.toast.error(e.message); }
|
|
} else {
|
|
try {
|
|
await API.patch(`/moderation/users/${uid}`, {
|
|
is_banned: 0,
|
|
ban_reason: null
|
|
});
|
|
UI.toast.success(`Sperre für ${name} aufgehoben.`);
|
|
_renderTab();
|
|
} catch (e) { UI.toast.error(e.message); }
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// TAB: FORUM
|
|
// ------------------------------------------------------------------
|
|
async function _renderForum(el) {
|
|
el.innerHTML = `
|
|
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
|
|
<button class="btn btn-ghost btn-sm" id="mod-forum-refresh">
|
|
${UI.icon('arrows-clockwise')} Aktualisieren
|
|
</button>
|
|
</div>
|
|
<div id="mod-forum-list">Lade…</div>
|
|
`;
|
|
el.querySelector('#mod-forum-refresh').addEventListener('click', () =>
|
|
_loadReports(el.querySelector('#mod-forum-list'))
|
|
);
|
|
await _loadReports(el.querySelector('#mod-forum-list'));
|
|
}
|
|
|
|
async function _loadReports(el) {
|
|
el.innerHTML = `<div style="padding:var(--space-4);text-align:center;
|
|
color:var(--c-text-muted)">Lade…</div>`;
|
|
const reports = await API.get('/moderation/reports');
|
|
if (!reports.length) {
|
|
el.innerHTML = _emptyState('check-circle', 'Keine offenen Meldungen', 'Alles sauber.');
|
|
return;
|
|
}
|
|
el.innerHTML = `
|
|
<div class="flex-col-gap-3">
|
|
${reports.map(r => `
|
|
<div class="card" style="padding:var(--space-4);
|
|
border-left:3px solid var(--c-danger)">
|
|
<div style="display:flex;align-items:flex-start;gap:var(--space-3)">
|
|
<div class="flex-1-min">
|
|
<div style="font-size:var(--text-xs);color:var(--c-text-muted);
|
|
margin-bottom:var(--space-1)">
|
|
${_esc(r.target_type)} #${r.target_id} ·
|
|
Gemeldet von <strong>${_esc(r.melder_name)}</strong>
|
|
</div>
|
|
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
|
color:var(--c-text);margin-bottom:var(--space-1)">
|
|
Grund: ${_esc(r.grund)}
|
|
</div>
|
|
${r.content_preview ? `
|
|
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
|
|
padding:var(--space-2) var(--space-3);
|
|
background:var(--c-surface-2);
|
|
border-radius:var(--radius-sm)">
|
|
${_esc(r.content_preview)}
|
|
</div>` : ''}
|
|
</div>
|
|
<button class="btn btn-sm btn-primary mod-resolve-btn"
|
|
data-rid="${r.id}" title="Als erledigt markieren">
|
|
${UI.icon('check')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
`;
|
|
|
|
el.querySelectorAll('.mod-resolve-btn').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
btn.disabled = true;
|
|
try {
|
|
await API.patch(`/moderation/reports/${btn.dataset.rid}`, {});
|
|
UI.toast.success('Meldung als erledigt markiert.');
|
|
await _loadReports(el);
|
|
} catch (e) { UI.toast.error(e.message); btn.disabled = false; }
|
|
});
|
|
});
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// HELPERS
|
|
// ------------------------------------------------------------------
|
|
function _emptyState(icon, title, text) {
|
|
return `
|
|
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
|
|
<svg class="ph-icon" style="width:40px;height:40px;color:var(--c-border);
|
|
margin-bottom:var(--space-3)" aria-hidden="true">
|
|
<use href="/icons/phosphor.svg#${icon}"></use>
|
|
</svg>
|
|
<p style="font-weight:var(--weight-semibold);color:var(--c-text);
|
|
margin:0 0 var(--space-1)">${title}</p>
|
|
${text ? `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin:0">${text}</p>` : ''}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// TAB: POI-KORREKTUREN
|
|
// ------------------------------------------------------------------
|
|
async function _renderPoiEdits(el) {
|
|
const edits = await API.get('/moderation/poi-edits');
|
|
if (!edits.length) {
|
|
el.innerHTML = _emptyState('check-circle', 'Alles erledigt', 'Keine ausstehenden POI-Korrekturen.');
|
|
return;
|
|
}
|
|
|
|
const STATUS_LABEL = { pending: 'Ausstehend', approved: 'Genehmigt', rejected: 'Abgelehnt' };
|
|
const STATUS_COLOR = { pending: 'var(--c-warning)', approved: 'var(--c-success,#22c55e)', rejected: 'var(--c-danger)' };
|
|
|
|
el.innerHTML = `
|
|
<div class="flex-col-gap-3">
|
|
${edits.map(e => `
|
|
<div class="card p-4" data-edit-id="${e.id}">
|
|
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:var(--space-2);flex-wrap:wrap">
|
|
<div>
|
|
<div style="font-weight:600">${_esc(e.poi_name)}</div>
|
|
<div class="text-xs-muted">
|
|
OSM-ID: ${_esc(e.osm_id)} · Feld: ${_esc(e.field)} · von ${_esc(e.einreicher_name)}
|
|
· ${new Date(e.created_at).toLocaleDateString('de-DE')}
|
|
</div>
|
|
</div>
|
|
<span style="font-size:var(--text-xs);font-weight:600;color:${STATUS_COLOR[e.status] || 'inherit'}">
|
|
${STATUS_LABEL[e.status] || e.status}
|
|
</span>
|
|
</div>
|
|
<div style="margin-top:var(--space-3);display:grid;grid-template-columns:1fr 1fr;gap:var(--space-2)">
|
|
<div style="background:var(--c-surface-2);border-radius:var(--radius-sm);padding:var(--space-2)">
|
|
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:2px">Aktuell</div>
|
|
<div class="text-sm">${_esc(e.old_value) || '<em class="text-muted">leer</em>'}</div>
|
|
</div>
|
|
<div style="background:var(--c-surface-2);border-radius:var(--radius-sm);padding:var(--space-2)">
|
|
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:2px">Vorschlag</div>
|
|
<div style="font-size:var(--text-sm);font-weight:600">${_esc(e.new_value)}</div>
|
|
</div>
|
|
</div>
|
|
${e.status === 'pending' ? `
|
|
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-3)">
|
|
<button class="btn btn-primary btn-sm flex-1" data-action="approve" data-id="${e.id}">
|
|
Übernehmen
|
|
</button>
|
|
<button class="btn btn-secondary btn-sm flex-1" data-action="reject" data-id="${e.id}">
|
|
Ablehnen
|
|
</button>
|
|
</div>` : ''}
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
`;
|
|
|
|
el.querySelectorAll('[data-action]').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
const id = parseInt(btn.dataset.id);
|
|
const action = btn.dataset.action;
|
|
btn.disabled = true;
|
|
try {
|
|
await API.patch(`/moderation/poi-edits/${id}`, { action });
|
|
UI.toast.success(action === 'approve' ? 'Übernommen!' : 'Abgelehnt.');
|
|
await _renderPoiEdits(el);
|
|
} catch (err) {
|
|
UI.toast.error(err.message || 'Fehler.');
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function _esc(s) {
|
|
if (!s) return '';
|
|
return String(s)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
return { init, refresh, onDogChange };
|
|
|
|
})();
|