Moderations-Panel: neue Seite /moderation für Mods und Admins
- Backend: routes/moderation.py mit GET /stats, /reports, /users, /fotos und PATCH-Endpoints für Ban/Unban und Foto-Review - Frontend: pages/moderation.js mit 4 Tabs (Übersicht, Fotos, User, Forum) - Sidebar-Eintrag (nur für Moderatoren/Admins sichtbar, gelb) - Page in index.html registriert, pages-Objekt in app.js ergänzt - Router in main.py eingebunden (/api/moderation) - SW-Cache by-v357, app.js/ui.js/api.js auf v=94
This commit is contained in:
parent
d0abb6de9b
commit
8ba8f4dfa3
6 changed files with 692 additions and 4 deletions
|
|
@ -185,6 +185,11 @@
|
|||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#camera"></use></svg> Social Media
|
||||
</div>
|
||||
|
||||
<div class="sidebar-item" data-page="moderation" id="sidebar-moderation"
|
||||
style="display:none;color:var(--c-warning,#f59e0b)">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#shield"></use></svg> Moderation
|
||||
</div>
|
||||
|
||||
<div class="sidebar-item" data-page="admin" id="sidebar-admin"
|
||||
style="display:none;color:var(--c-danger,#ef4444)">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#shield"></use></svg> Admin
|
||||
|
|
@ -338,6 +343,10 @@
|
|||
<div class="page-body page-container"></div>
|
||||
</section>
|
||||
|
||||
<section class="page" id="page-moderation">
|
||||
<div class="page-body page-container"></div>
|
||||
</section>
|
||||
|
||||
<section class="page" id="page-friends">
|
||||
<div class="page-body page-container"></div>
|
||||
</section>
|
||||
|
|
@ -397,9 +406,9 @@
|
|||
<div id="modal-container"></div>
|
||||
|
||||
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
|
||||
<script src="/js/api.js?v=93"></script>
|
||||
<script src="/js/ui.js?v=93"></script>
|
||||
<script src="/js/app.js?v=93"></script>
|
||||
<script src="/js/api.js?v=94"></script>
|
||||
<script src="/js/ui.js?v=94"></script>
|
||||
<script src="/js/app.js?v=94"></script>
|
||||
|
||||
<!-- Feature-Seiten werden lazy geladen -->
|
||||
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ const App = (() => {
|
|||
chat: { title: 'Nachrichten', module: null, requiresAuth: true },
|
||||
social: { title: 'Social Media', module: null, requiresAuth: true },
|
||||
admin: { title: 'Admin', module: null, requiresAuth: true },
|
||||
moderation: { title: 'Moderation', module: null, requiresAuth: true },
|
||||
impressum: { title: 'Impressum', module: null },
|
||||
datenschutz: { title: 'Datenschutz', module: null },
|
||||
widget: { title: 'Widget', module: null, requiresAuth: true },
|
||||
|
|
@ -422,6 +423,12 @@ const App = (() => {
|
|||
|| state.user.is_moderator;
|
||||
adminItem.style.display = isMod ? '' : 'none';
|
||||
}
|
||||
const moderationItem = document.getElementById('sidebar-moderation');
|
||||
if (moderationItem) {
|
||||
const isMod = state.user.rolle === 'admin' || state.user.rolle === 'moderator'
|
||||
|| state.user.is_moderator;
|
||||
moderationItem.style.display = isMod ? '' : 'none';
|
||||
}
|
||||
const socialItem = document.getElementById('sidebar-social');
|
||||
if (socialItem) {
|
||||
const isSocial = state.user.is_social_media || state.user.rolle === 'admin';
|
||||
|
|
|
|||
447
backend/static/js/pages/moderation.js
Normal file
447
backend/static/js/pages/moderation.js
Normal file
|
|
@ -0,0 +1,447 @@
|
|||
/* ============================================================
|
||||
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' },
|
||||
];
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
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;
|
||||
}
|
||||
} catch (e) {
|
||||
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.');
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TAB: ÜBERSICHT
|
||||
// ------------------------------------------------------------------
|
||||
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)')}
|
||||
${_statCard('image',
|
||||
'Fotos ausstehend',
|
||||
s.pending_fotos,
|
||||
s.pending_fotos > 0 ? 'var(--c-warning)' : 'var(--c-text-muted)')}
|
||||
${_statCard('skull',
|
||||
'Gesperrte User',
|
||||
s.banned_users,
|
||||
s.banned_users > 0 ? '#f59e0b' : 'var(--c-text-muted)')}
|
||||
${_statCard('storefront',
|
||||
'Züchter ausstehend',
|
||||
s.pending_zuchter,
|
||||
s.pending_zuchter > 0 ? 'var(--c-warning)' : 'var(--c-text-muted)')}
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding:var(--space-4);margin-top:var(--space-4)">
|
||||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.6">
|
||||
${UI.icon('info')}
|
||||
Das Moderations-Panel zeigt dir alle ausstehenden Aufgaben auf einen Blick.
|
||||
Verwende die Tabs oben für Details zu Fotos, Usern und Forum-Meldungen.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function _statCard(icon, label, value, color) {
|
||||
return `
|
||||
<div class="card" style="padding:var(--space-4);text-align:center">
|
||||
<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>
|
||||
</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" style="padding:var(--space-4)">
|
||||
<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)">
|
||||
<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-3)">
|
||||
von ${_esc(f.user_name)}
|
||||
</div>
|
||||
${f.aktuell_foto ? `
|
||||
<img src="${_esc(f.aktuell_foto)}" alt="Aktuell"
|
||||
style="width:100%;height:80px;object-fit:cover;
|
||||
border-radius:var(--radius-sm);opacity:.5;
|
||||
margin-bottom:var(--space-2)">
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);
|
||||
margin-bottom:var(--space-3)">aktuelles Foto</div>
|
||||
` : ''}
|
||||
<div style="display:flex;gap:var(--space-2)">
|
||||
<button class="btn btn-sm btn-primary mod-foto-approve"
|
||||
data-id="${f.id}" style="flex:1">Freigeben</button>
|
||||
<button class="btn btn-sm btn-ghost mod-foto-reject"
|
||||
data-id="${f.id}" style="color:var(--c-danger)">Ablehnen</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
el.querySelectorAll('.mod-foto-approve').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await API.patch(`/moderation/fotos/${btn.dataset.id}`, { action: 'approve' });
|
||||
UI.toast.success('Foto freigegeben.');
|
||||
await _loadFotos(el);
|
||||
} catch (e) { UI.toast.error(e.message); btn.disabled = false; }
|
||||
});
|
||||
});
|
||||
|
||||
el.querySelectorAll('.mod-foto-reject').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await API.patch(`/moderation/fotos/${btn.dataset.id}`, {
|
||||
action: 'reject',
|
||||
reject_reason: 'Nicht geeignet.'
|
||||
});
|
||||
UI.toast.success('Foto abgelehnt.');
|
||||
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) {
|
||||
if (!users.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 style="display:flex;flex-direction:column;gap:var(--space-2)">
|
||||
${users.map(u => `
|
||||
<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 style="flex:1;min-width:0">
|
||||
<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 style="font-size:var(--text-xs);color:var(--c-text-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">
|
||||
${u.is_banned
|
||||
? `<button class="btn btn-sm btn-ghost mod-unban"
|
||||
data-uid="${u.id}" data-name="${_esc(u.name)}"
|
||||
title="Sperre aufheben" style="color:var(--c-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" style="color:var(--c-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 style="display:flex;flex-direction:column;gap:var(--space-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 style="flex:1;min-width:0">
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
|
||||
function _esc(s) {
|
||||
if (!s) return '';
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
return { init, refresh, onDogChange };
|
||||
|
||||
})();
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
Offline-Cache + Push Notifications + Tile-Cache
|
||||
============================================================ */
|
||||
|
||||
const CACHE_VERSION = 'by-v356';
|
||||
const CACHE_VERSION = 'by-v357';
|
||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue