Sprint 11: Freunde & Chat + Phosphor-Icon-Vollmigration
- Freundschaften (pending/accepted), Nutzersuche, Anfragen per Push - Direktnachrichten mit Polling, iMessage-Stil, Deep-Links aus Push - Alle Seiten (map, places, diary, health, dog-profile, sitting, knigge, forum, wiki, walks) vollständig auf Phosphor-Icons migriert - Wikidata-Rassen-Scraper (~833 neue Rassen, lokal gespiegelte Fotos) - TheDogAPI lokal gespiegelt (169 Rassen + Fotos) - Quiz-Result-Cards horizontal (korrekte Bildproportionen) - SW by-v89
This commit is contained in:
parent
96bd57f0ad
commit
097295c628
44 changed files with 9980 additions and 300 deletions
344
backend/static/js/pages/chat.js
Normal file
344
backend/static/js/pages/chat.js
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
/* ============================================================
|
||||
BAN YARO — Chat-Seite
|
||||
============================================================ */
|
||||
|
||||
window.Page_chat = (() => {
|
||||
|
||||
let _container = null;
|
||||
let _view = 'list'; // 'list' | 'thread'
|
||||
let _convId = null;
|
||||
let _partnerName = '';
|
||||
let _myId = null;
|
||||
let _pollTimer = null;
|
||||
let _lastMsgId = 0;
|
||||
|
||||
// ----------------------------------------------------------
|
||||
async function init(container, appState, params = {}) {
|
||||
_container = container;
|
||||
_myId = appState?.user?.id || null;
|
||||
|
||||
if (params.conversation_id) {
|
||||
await _openThread(params.conversation_id);
|
||||
} else {
|
||||
await _showList();
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Conversation list
|
||||
// ----------------------------------------------------------
|
||||
async function _showList() {
|
||||
_view = 'list';
|
||||
_stopPolling();
|
||||
_convId = null;
|
||||
|
||||
_container.innerHTML = `
|
||||
<div style="background:var(--c-surface)">
|
||||
<div style="padding:var(--space-4) var(--space-4) var(--space-2)">
|
||||
<h2 style="font-size:var(--text-xl);font-weight:var(--weight-bold)">Nachrichten</h2>
|
||||
</div>
|
||||
<div id="chat-list-body"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
await _loadList();
|
||||
await _updateChatBadge();
|
||||
}
|
||||
|
||||
async function _loadList() {
|
||||
const el = document.getElementById('chat-list-body');
|
||||
if (!el) return;
|
||||
try {
|
||||
const convs = await API.chat.conversations();
|
||||
if (!convs.length) {
|
||||
el.innerHTML = `
|
||||
<div class="empty-state" style="padding:var(--space-12) var(--space-4)">
|
||||
<svg class="ph-icon" style="font-size:3rem;opacity:0.3">
|
||||
<use href="/icons/phosphor.svg#chat-circle-dots"></use>
|
||||
</svg>
|
||||
<p style="margin-top:var(--space-3);color:var(--c-text-muted)">
|
||||
Noch keine Nachrichten.<br>
|
||||
Schreibe einem Freund über die Freunde-Seite!
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
el.innerHTML = convs.map(c => {
|
||||
const initials = (c.partner_name || '?')[0].toUpperCase();
|
||||
const preview = c.last_text
|
||||
? _esc(c.last_text.substring(0, 60)) + (c.last_text.length > 60 ? '…' : '')
|
||||
: '<em style="opacity:0.6">Noch keine Nachrichten</em>';
|
||||
const timeStr = c.last_msg_at ? _fmtTime(c.last_msg_at) : '';
|
||||
const badge = c.unread_count > 0
|
||||
? `<span class="chat-unread-badge">${c.unread_count}</span>`
|
||||
: '';
|
||||
return `
|
||||
<div class="chat-conv-item" onclick="Page_chat._openThread(${c.id})">
|
||||
<div class="chat-conv-avatar">${initials}</div>
|
||||
<div class="chat-conv-info">
|
||||
<div class="chat-conv-name">${_esc(c.partner_name)}</div>
|
||||
<div class="chat-conv-preview">${preview}</div>
|
||||
</div>
|
||||
<div class="chat-conv-meta">
|
||||
<span class="chat-conv-time">${timeStr}</span>
|
||||
${badge}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
if (e.status === 401) {
|
||||
document.getElementById('chat-list-body').innerHTML =
|
||||
`<div class="empty-state"><p>Bitte melde dich an.</p></div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Thread (message view)
|
||||
// ----------------------------------------------------------
|
||||
async function _openThread(convId) {
|
||||
_convId = convId;
|
||||
_view = 'thread';
|
||||
_stopPolling();
|
||||
|
||||
_container.innerHTML = `
|
||||
<div class="chat-thread" id="chat-thread">
|
||||
<div class="chat-thread-header">
|
||||
<button class="btn btn-ghost btn-sm" onclick="Page_chat._showList()" style="padding:var(--space-1)">
|
||||
<svg class="ph-icon"><use href="/icons/phosphor.svg#arrow-left"></use></svg>
|
||||
</button>
|
||||
<div class="chat-conv-avatar" id="chat-partner-av" style="width:32px;height:32px;font-size:var(--text-sm)">?</div>
|
||||
<span class="chat-thread-partner" id="chat-partner-name">…</span>
|
||||
</div>
|
||||
<div class="chat-messages" id="chat-messages">
|
||||
<div style="text-align:center;padding:var(--space-6);color:var(--c-text-muted);font-size:var(--text-sm)">
|
||||
Laden…
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-input-bar">
|
||||
<textarea id="chat-input" class="chat-input" rows="1"
|
||||
placeholder="Nachricht…" maxlength="2000"></textarea>
|
||||
<button class="chat-send-btn" id="chat-send-btn" onclick="Page_chat._send()">
|
||||
<svg class="ph-icon"><use href="/icons/phosphor.svg#paper-plane-tilt"></use></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Auto-resize textarea
|
||||
const input = document.getElementById('chat-input');
|
||||
input.addEventListener('input', () => {
|
||||
input.style.height = 'auto';
|
||||
input.style.height = Math.min(input.scrollHeight, 100) + 'px';
|
||||
});
|
||||
input.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
Page_chat._send();
|
||||
}
|
||||
});
|
||||
|
||||
await _loadMessages(true);
|
||||
await API.chat.markRead(_convId).catch(() => {});
|
||||
await _updateChatBadge();
|
||||
|
||||
// Poll every 4s while thread is open
|
||||
_pollTimer = setInterval(async () => {
|
||||
if (_view === 'thread' && _convId === convId) {
|
||||
await _pollNew();
|
||||
}
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
async function _loadMessages(scroll = false) {
|
||||
const el = document.getElementById('chat-messages');
|
||||
if (!el) return;
|
||||
try {
|
||||
const data = await API.chat.messages(_convId);
|
||||
_partnerName = data.partner_name;
|
||||
|
||||
const nameEl = document.getElementById('chat-partner-name');
|
||||
const avEl = document.getElementById('chat-partner-av');
|
||||
if (nameEl) nameEl.textContent = data.partner_name;
|
||||
if (avEl) avEl.textContent = (data.partner_name || '?')[0].toUpperCase();
|
||||
|
||||
if (!data.messages.length) {
|
||||
el.innerHTML = `
|
||||
<div style="text-align:center;padding:var(--space-8);color:var(--c-text-muted);font-size:var(--text-sm)">
|
||||
Schreibe die erste Nachricht!
|
||||
</div>
|
||||
`;
|
||||
_lastMsgId = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
_lastMsgId = data.messages[data.messages.length - 1].id;
|
||||
el.innerHTML = _renderMessages(data.messages);
|
||||
|
||||
if (scroll) _scrollToBottom(el);
|
||||
} catch (e) {
|
||||
if (el) el.innerHTML = `<div class="empty-state"><p>Fehler beim Laden.</p></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function _pollNew() {
|
||||
const el = document.getElementById('chat-messages');
|
||||
if (!el) return;
|
||||
try {
|
||||
const data = await API.chat.messages(_convId);
|
||||
if (!data.messages.length) return;
|
||||
|
||||
const newest = data.messages[data.messages.length - 1];
|
||||
if (newest.id <= _lastMsgId) return;
|
||||
|
||||
// New messages arrived
|
||||
_lastMsgId = newest.id;
|
||||
const wasAtBottom = _isScrolledToBottom(el);
|
||||
el.innerHTML = _renderMessages(data.messages);
|
||||
if (wasAtBottom) _scrollToBottom(el);
|
||||
|
||||
await API.chat.markRead(_convId).catch(() => {});
|
||||
await _updateChatBadge();
|
||||
} catch (e) {
|
||||
// silent
|
||||
}
|
||||
}
|
||||
|
||||
function _renderMessages(msgs) {
|
||||
let html = '';
|
||||
let lastDate = null;
|
||||
|
||||
for (const m of msgs) {
|
||||
const dateStr = m.created_at.substring(0, 10);
|
||||
if (dateStr !== lastDate) {
|
||||
html += `<div class="chat-date-divider">${_fmtDate(m.created_at)}</div>`;
|
||||
lastDate = dateStr;
|
||||
}
|
||||
|
||||
const isMine = m.sender_id === _myId;
|
||||
const rowClass = isMine ? 'chat-bubble-row--mine' : 'chat-bubble-row--theirs';
|
||||
const bubClass = isMine ? 'chat-bubble--mine' : 'chat-bubble--theirs';
|
||||
const delClass = m.is_deleted ? ' chat-bubble--deleted' : '';
|
||||
const timeStr = _fmtTime(m.created_at);
|
||||
const deleteBtn = isMine && !m.is_deleted
|
||||
? `<button class="btn btn-ghost" style="padding:2px;opacity:0.4;font-size:var(--text-xs)"
|
||||
onclick="Page_chat._deleteMsg(${m.id})" title="Löschen">
|
||||
<svg class="ph-icon" style="width:12px;height:12px"><use href="/icons/phosphor.svg#trash"></use></svg>
|
||||
</button>`
|
||||
: '';
|
||||
|
||||
html += `
|
||||
<div class="chat-bubble-row ${rowClass}">
|
||||
<div>
|
||||
<div class="chat-bubble ${bubClass}${delClass}">${_esc(m.text)}</div>
|
||||
<div class="chat-bubble-time" style="display:flex;align-items:center;gap:2px;justify-content:${isMine ? 'flex-end' : 'flex-start'}">
|
||||
${timeStr} ${deleteBtn}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
async function _send() {
|
||||
const input = document.getElementById('chat-input');
|
||||
const btn = document.getElementById('chat-send-btn');
|
||||
if (!input) return;
|
||||
const text = input.value.trim();
|
||||
if (!text || !_convId) return;
|
||||
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await API.chat.send(_convId, text);
|
||||
input.value = '';
|
||||
input.style.height = 'auto';
|
||||
await _loadMessages(true);
|
||||
} catch (e) {
|
||||
UI.toast(e.message, 'danger');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
async function _deleteMsg(msgId) {
|
||||
try {
|
||||
await API.chat.deleteMessage(msgId);
|
||||
await _loadMessages(false);
|
||||
} catch (e) {
|
||||
UI.toast(e.message, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
async function _updateChatBadge() {
|
||||
try {
|
||||
const convs = await API.chat.conversations();
|
||||
const total = convs.reduce((s, c) => s + (c.unread_count || 0), 0);
|
||||
const badge = document.getElementById('chat-badge');
|
||||
if (badge) {
|
||||
badge.textContent = total;
|
||||
badge.style.display = total > 0 ? '' : 'none';
|
||||
}
|
||||
} catch (e) {
|
||||
// silent
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Helpers
|
||||
// ----------------------------------------------------------
|
||||
function _stopPolling() {
|
||||
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; }
|
||||
}
|
||||
|
||||
function _scrollToBottom(el) {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
function _isScrolledToBottom(el) {
|
||||
return el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
||||
}
|
||||
|
||||
function _fmtTime(iso) {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso.replace(' ', 'T') + 'Z');
|
||||
const now = new Date();
|
||||
const isToday = d.toDateString() === now.toDateString();
|
||||
if (isToday) return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
|
||||
}
|
||||
|
||||
function _fmtDate(iso) {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso.replace(' ', 'T') + 'Z');
|
||||
const now = new Date();
|
||||
const diff = Math.floor((now - d) / 86400000);
|
||||
if (diff === 0) return 'Heute';
|
||||
if (diff === 1) return 'Gestern';
|
||||
return d.toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' });
|
||||
}
|
||||
|
||||
function _esc(s) {
|
||||
if (!s) return '';
|
||||
return String(s)
|
||||
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')
|
||||
.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
return {
|
||||
init,
|
||||
_showList,
|
||||
_openThread,
|
||||
_send,
|
||||
_deleteMsg,
|
||||
};
|
||||
|
||||
})();
|
||||
|
|
@ -16,12 +16,12 @@ window.Page_diary = (() => {
|
|||
const LIMIT = 20;
|
||||
|
||||
const TYPEN = {
|
||||
eintrag: { label: 'Eintrag', icon: '📖' },
|
||||
foto: { label: 'Foto', icon: '📷' },
|
||||
meilenstein:{ label: 'Meilenstein',icon: '🏆' },
|
||||
training: { label: 'Training', icon: '🎯' },
|
||||
gesundheit: { label: 'Gesundheit', icon: '💉' },
|
||||
ausflug: { label: 'Ausflug', icon: '🚗' },
|
||||
eintrag: { label: 'Eintrag', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg>' },
|
||||
foto: { label: 'Foto', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#camera"></use></svg>' },
|
||||
meilenstein:{ label: 'Meilenstein',icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trophy"></use></svg>' },
|
||||
training: { label: 'Training', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#target"></use></svg>' },
|
||||
gesundheit: { label: 'Gesundheit', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>' },
|
||||
ausflug: { label: 'Ausflug', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#car"></use></svg>' },
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -72,7 +72,7 @@ window.Page_diary = (() => {
|
|||
async function _render() {
|
||||
if (!_appState.activeDog) {
|
||||
_container.innerHTML = UI.emptyState({
|
||||
icon: '🐕',
|
||||
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>',
|
||||
title: 'Noch kein Hund angelegt',
|
||||
text: 'Erstelle zuerst ein Hundeprofil, um das Tagebuch zu nutzen.',
|
||||
action: `<button class="btn btn-primary" id="diary-goto-profile">Profil erstellen</button>`,
|
||||
|
|
@ -99,7 +99,7 @@ window.Page_diary = (() => {
|
|||
const isActive = dog.id === activeDogId;
|
||||
const av = dog.foto_url
|
||||
? `<img src="${_escape(dog.foto_url)}" alt="${_escape(dog.name)}">`
|
||||
: `<span style="font-size:2.5rem">🐕</span>`;
|
||||
: `<span>${UI.icon('dog')}</span>`;
|
||||
return `
|
||||
<div class="diary-picker-card${isActive ? ' diary-picker-card--active' : ''}"
|
||||
data-dog-id="${dog.id}">
|
||||
|
|
@ -186,7 +186,7 @@ window.Page_diary = (() => {
|
|||
|
||||
if (_entries.length === 0) {
|
||||
listEl.innerHTML = UI.emptyState({
|
||||
icon: '📖',
|
||||
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg>',
|
||||
title: 'Noch keine Einträge',
|
||||
text: 'Halte besondere Momente mit deinem Hund fest.',
|
||||
action: `<button class="btn btn-primary" id="diary-first-entry">Ersten Eintrag erstellen</button>`,
|
||||
|
|
@ -243,6 +243,11 @@ window.Page_diary = (() => {
|
|||
? `<p class="diary-card-text">${_escape(e.text.slice(0, 140))}${e.text.length > 140 ? '…' : ''}</p>`
|
||||
: '';
|
||||
|
||||
// Meilenstein-Badge (nur bei is_milestone=1, nicht bei manuell gewähltem Typ 'meilenstein')
|
||||
const milestoneBadge = e.is_milestone
|
||||
? `<div class="diary-card-milestone-badge">${UI.icon('calendar-dots')} Meilenstein</div>`
|
||||
: '';
|
||||
|
||||
// Mehrere Hunde: kleine Avatare in der Karte
|
||||
const dogAvatars = _dogAvatarRow(e.dog_ids || []);
|
||||
|
||||
|
|
@ -250,6 +255,7 @@ window.Page_diary = (() => {
|
|||
<div class="diary-card${isMile ? ' diary-card--milestone' : ''}" data-entry-id="${e.id}">
|
||||
${photo}
|
||||
<div class="diary-card-body">
|
||||
${milestoneBadge}
|
||||
<div class="diary-card-meta">
|
||||
<span class="diary-card-type">${typ.icon} ${typ.label}</span>
|
||||
<span class="diary-card-date">${dateStr}</span>
|
||||
|
|
@ -269,7 +275,7 @@ window.Page_diary = (() => {
|
|||
const dog = _appState.dogs.find(d => d.id === did);
|
||||
if (!dog) return '';
|
||||
return `<div class="diary-dog-av" title="${_escape(dog.name)}">
|
||||
${dog.foto_url ? `<img src="${_escape(dog.foto_url)}" alt="">` : '<span>🐕</span>'}
|
||||
${dog.foto_url ? `<img src="${_escape(dog.foto_url)}" alt="">` : `<span>${UI.icon('dog')}</span>`}
|
||||
</div>`;
|
||||
}).join('');
|
||||
return `<div class="diary-dog-row">${avatars}</div>`;
|
||||
|
|
@ -299,7 +305,7 @@ window.Page_diary = (() => {
|
|||
const dog = _appState.dogs.find(d => d.id === did);
|
||||
return dog ? `<div class="diary-dog-chip">
|
||||
<div class="diary-dog-av">
|
||||
${dog.foto_url ? `<img src="${_escape(dog.foto_url)}" alt="">` : '<span>🐕</span>'}
|
||||
${dog.foto_url ? `<img src="${_escape(dog.foto_url)}" alt="">` : `<span>${UI.icon('dog')}</span>`}
|
||||
</div>
|
||||
<span>${_escape(dog.name)}</span>
|
||||
</div>` : '';
|
||||
|
|
@ -308,7 +314,7 @@ window.Page_diary = (() => {
|
|||
: '';
|
||||
|
||||
const body = `
|
||||
${isMile ? '<div class="diary-detail-milestone-badge">🏆 Meilenstein</div>' : ''}
|
||||
${isMile ? `<div class="diary-detail-milestone-badge">${UI.icon('trophy')} Meilenstein</div>` : ''}
|
||||
${photo}
|
||||
<div style="display:flex;gap:var(--space-2);align-items:center;margin-bottom:var(--space-3)">
|
||||
<span class="badge badge-primary">${typ.icon} ${typ.label}</span>
|
||||
|
|
@ -374,7 +380,7 @@ window.Page_diary = (() => {
|
|||
<input type="checkbox" name="extra_dog" value="${d.id}"
|
||||
${entryDogIds.includes(d.id) ? 'checked' : ''}>
|
||||
<div class="diary-dog-av">
|
||||
${d.foto_url ? `<img src="${_escape(d.foto_url)}" alt="">` : '<span>🐕</span>'}
|
||||
${d.foto_url ? `<img src="${_escape(d.foto_url)}" alt="">` : `<span>${UI.icon('dog')}</span>`}
|
||||
</div>
|
||||
<span>${_escape(d.name)}</span>
|
||||
</label>`).join('')}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ window.Page_dog_profile = (() => {
|
|||
async function _render() {
|
||||
if (!_appState.user) {
|
||||
_container.innerHTML = UI.emptyState({
|
||||
icon : '🐕',
|
||||
icon : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>',
|
||||
title : 'Anmelden erforderlich',
|
||||
text : 'Melde dich an, um ein Hundeprofil anzulegen.',
|
||||
action: `<button class="btn btn-primary" id="profile-goto-login">Anmelden</button>`,
|
||||
|
|
@ -85,13 +85,13 @@ window.Page_dog_profile = (() => {
|
|||
: `<div style="width:120px;height:120px;border-radius:50%;
|
||||
background:var(--c-surface-2);display:flex;
|
||||
align-items:center;justify-content:center;
|
||||
font-size:3.5rem;border:3px solid var(--c-border)">🐕</div>`}
|
||||
font-size:3.5rem;border:3px solid var(--c-border)">${UI.icon('dog')}</div>`}
|
||||
<label style="position:absolute;bottom:4px;right:4px;
|
||||
background:var(--c-primary);color:#fff;border-radius:50%;
|
||||
width:30px;height:30px;display:flex;align-items:center;
|
||||
justify-content:center;cursor:pointer;font-size:14px"
|
||||
title="Foto ändern">
|
||||
📷
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#camera"></use></svg>
|
||||
<input type="file" id="dp-photo-input" accept="image/*"
|
||||
style="display:none">
|
||||
</label>
|
||||
|
|
@ -110,7 +110,7 @@ window.Page_dog_profile = (() => {
|
|||
${geburtstag ? `
|
||||
<div class="card" style="padding:var(--space-3)">
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
|
||||
margin-bottom:2px">🎂 Geburtstag</div>
|
||||
margin-bottom:2px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#calendar-dots"></use></svg> Geburtstag</div>
|
||||
<div style="font-weight:500;font-size:var(--text-sm)">${geburtstag}</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
|
||||
${_calcAlter(dog.geburtstag)}
|
||||
|
|
@ -120,7 +120,7 @@ window.Page_dog_profile = (() => {
|
|||
${dog.geschlecht ? `
|
||||
<div class="card" style="padding:var(--space-3)">
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
|
||||
margin-bottom:2px">${dog.geschlecht === 'm' ? '♂' : '♀'} Geschlecht</div>
|
||||
margin-bottom:2px">${dog.geschlecht === 'm' ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#gender-male"></use></svg>' : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#gender-female"></use></svg>'} Geschlecht</div>
|
||||
<div style="font-weight:500;font-size:var(--text-sm)">
|
||||
${dog.geschlecht === 'm' ? 'Rüde' : 'Hündin'}
|
||||
</div>
|
||||
|
|
@ -129,14 +129,14 @@ window.Page_dog_profile = (() => {
|
|||
${dog.gewicht_kg ? `
|
||||
<div class="card" style="padding:var(--space-3)">
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
|
||||
margin-bottom:2px">⚖️ Gewicht</div>
|
||||
margin-bottom:2px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#scales"></use></svg> Gewicht</div>
|
||||
<div style="font-weight:500;font-size:var(--text-sm)">${dog.gewicht_kg} kg</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${dog.chip_nr ? `
|
||||
<div class="card" style="padding:var(--space-3)">
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
|
||||
margin-bottom:2px">💾 Chip-Nr.</div>
|
||||
margin-bottom:2px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#tag"></use></svg> Chip-Nr.</div>
|
||||
<div style="font-size:var(--text-xs);font-weight:500;
|
||||
word-break:break-all">${_esc(dog.chip_nr)}</div>
|
||||
</div>
|
||||
|
|
@ -151,6 +151,34 @@ window.Page_dog_profile = (() => {
|
|||
</div>
|
||||
` : ''}
|
||||
|
||||
${dog.is_public ? `
|
||||
<div style="background:var(--c-primary-subtle);border:1px solid var(--c-primary-light);
|
||||
border-radius:var(--radius-md);padding:var(--space-4);
|
||||
margin-bottom:var(--space-5);text-align:left">
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
|
||||
margin-bottom:var(--space-2);font-weight:var(--weight-medium)">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#tag"></use></svg> NFC-Link
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:var(--space-2);
|
||||
flex-wrap:wrap">
|
||||
<code id="dp-nfc-link"
|
||||
style="flex:1;font-size:var(--text-sm);background:var(--c-surface);
|
||||
border:1px solid var(--c-border);border-radius:var(--radius-sm);
|
||||
padding:var(--space-2) var(--space-3);color:var(--c-text);
|
||||
word-break:break-all">banyaro.app/hund/${dog.id}</code>
|
||||
<button class="btn btn-secondary btn-sm" id="dp-copy-link-btn"
|
||||
style="flex-shrink:0;padding:var(--space-2) var(--space-3);
|
||||
font-size:var(--text-sm);min-height:36px">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#clipboard-text"></use></svg> Kopieren
|
||||
</button>
|
||||
</div>
|
||||
<p style="margin:var(--space-2) 0 0;font-size:var(--text-xs);
|
||||
color:var(--c-text-muted)">
|
||||
Dieser Link kann auf ein NFC-Tag gebrannt werden
|
||||
</p>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
|
||||
<button class="btn btn-primary w-full" id="dp-edit-btn">
|
||||
Profil bearbeiten
|
||||
|
|
@ -182,6 +210,26 @@ window.Page_dog_profile = (() => {
|
|||
UI.toast.error(err.message || 'Fehler beim Hochladen.');
|
||||
}
|
||||
});
|
||||
// NFC-Link kopieren
|
||||
document.getElementById('dp-copy-link-btn')?.addEventListener('click', async () => {
|
||||
const url = `https://banyaro.app/hund/${dog.id}`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
UI.toast.success('Link kopiert!');
|
||||
} catch {
|
||||
// Fallback für ältere Browser
|
||||
const el = document.getElementById('dp-nfc-link');
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
document.execCommand('copy');
|
||||
sel.removeAllRanges();
|
||||
UI.toast.success('Link kopiert!');
|
||||
}
|
||||
});
|
||||
|
||||
// Edit- und Add-Klicks laufen über Event-Delegation in init() — keine direkten Listener nötig.
|
||||
}
|
||||
|
||||
|
|
@ -192,7 +240,7 @@ window.Page_dog_profile = (() => {
|
|||
_container.innerHTML = `
|
||||
<div style="padding:var(--space-4) 0 var(--space-2)">
|
||||
<div style="text-align:center;margin-bottom:var(--space-5)">
|
||||
<div style="font-size:3rem;margin-bottom:var(--space-2)">🐕</div>
|
||||
<div style="font-size:3rem;margin-bottom:var(--space-2)">${UI.icon('dog')}</div>
|
||||
<h2 style="font-size:var(--text-xl);font-weight:700;margin:0 0 var(--space-2)">
|
||||
Hund anlegen
|
||||
</h2>
|
||||
|
|
@ -215,7 +263,7 @@ window.Page_dog_profile = (() => {
|
|||
body: _formHTML(null, true),
|
||||
footer: `
|
||||
<button type="button" class="btn btn-secondary flex-1" id="dp-form-cancel">Abbrechen</button>
|
||||
<button type="submit" form="dp-form" class="btn btn-primary flex-1">🐕 Hund anlegen</button>
|
||||
<button type="submit" form="dp-form" class="btn btn-primary flex-1">${UI.icon('dog')} Hund anlegen</button>
|
||||
`,
|
||||
});
|
||||
_bindForm(null, true);
|
||||
|
|
@ -317,7 +365,7 @@ window.Page_dog_profile = (() => {
|
|||
background:var(--c-surface-2);border:2px solid var(--c-border);
|
||||
display:${dog?.foto_url ? 'block' : 'none'}">
|
||||
<label class="btn btn-secondary btn-sm" style="cursor:pointer;margin:0">
|
||||
📷 Foto auswählen
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#camera"></use></svg> Foto auswählen
|
||||
<input type="file" name="foto" accept="image/*" style="display:none"
|
||||
id="dp-form-foto">
|
||||
</label>
|
||||
|
|
@ -329,7 +377,7 @@ window.Page_dog_profile = (() => {
|
|||
${dog ? `<button type="button" class="btn btn-secondary flex-1"
|
||||
id="dp-form-cancel">Abbrechen</button>` : ''}
|
||||
<button type="submit" class="btn btn-primary flex-1">
|
||||
${dog ? 'Speichern' : '🐕 Hund anlegen'}
|
||||
${dog ? 'Speichern' : `${UI.icon('dog')} Hund anlegen`}
|
||||
</button>
|
||||
</div>` : ''}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,10 +34,18 @@ window.Page_events = (() => {
|
|||
let _state = null;
|
||||
let _events = [];
|
||||
let _filter = 'alle';
|
||||
let _quellFilter = 'alle'; // 'alle' | 'vdh' | 'nutzer'
|
||||
let _view = 'liste'; // liste | karte
|
||||
let _map = null;
|
||||
let _markers = [];
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Phosphor-Icon-Helper
|
||||
// ----------------------------------------------------------
|
||||
function _icon(name) {
|
||||
return `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${name}"></use></svg>`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// init
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -59,11 +67,11 @@ window.Page_events = (() => {
|
|||
_container.innerHTML = `
|
||||
<div class="events-toolbar">
|
||||
<div class="events-view-toggle">
|
||||
<button class="events-view-btn active" data-ev-view="liste">☰ Liste</button>
|
||||
<button class="events-view-btn" data-ev-view="karte">🗺️ Karte</button>
|
||||
<button class="events-view-btn active" data-ev-view="liste">${UI.icon('list')} Liste</button>
|
||||
<button class="events-view-btn" data-ev-view="karte">${UI.icon('map-trifold')} Karte</button>
|
||||
</div>
|
||||
<div style="flex:1"></div>
|
||||
${_state.user ? `<button class="btn btn-primary btn-sm" id="ev-new-btn">+ Event</button>` : ''}
|
||||
${_state.user ? `<button class="btn btn-primary btn-sm" id="ev-new-btn">${UI.icon('plus')} Event</button>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="events-filter-bar" id="ev-filter-bar">
|
||||
|
|
@ -74,6 +82,14 @@ window.Page_events = (() => {
|
|||
`).join('')}
|
||||
</div>
|
||||
|
||||
<div class="events-source-bar" id="ev-source-bar">
|
||||
<button class="events-source-btn active" data-ev-quelle="alle">Alle Quellen</button>
|
||||
<button class="events-source-btn events-source-vdh" data-ev-quelle="vdh">
|
||||
<span class="ev-vdh-badge">VDH</span> VDH-Events
|
||||
</button>
|
||||
<button class="events-source-btn" data-ev-quelle="nutzer">Von Nutzern</button>
|
||||
</div>
|
||||
|
||||
<div class="events-list" id="ev-list"></div>
|
||||
<div class="events-map" id="ev-map" style="display:none"></div>
|
||||
`;
|
||||
|
|
@ -96,6 +112,17 @@ window.Page_events = (() => {
|
|||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Gefilterte Events ermitteln
|
||||
// ----------------------------------------------------------
|
||||
function _filtered() {
|
||||
let evs = _filter === 'alle' ? _events : _events.filter(e => e.typ === _filter);
|
||||
if (_quellFilter !== 'alle') {
|
||||
evs = evs.filter(e => (e.quelle || 'nutzer') === _quellFilter);
|
||||
}
|
||||
return evs;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Liste rendern
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -103,7 +130,7 @@ window.Page_events = (() => {
|
|||
const listEl = document.getElementById('ev-list');
|
||||
if (!listEl) return;
|
||||
|
||||
const filtered = _filter === 'alle' ? _events : _events.filter(e => e.typ === _filter);
|
||||
const filtered = _filtered();
|
||||
if (!filtered.length) {
|
||||
listEl.innerHTML = UI.emptyState({ icon: '🎪', title: 'Keine Events', text: 'Noch keine Veranstaltungen geplant.' });
|
||||
return;
|
||||
|
|
@ -140,6 +167,8 @@ window.Page_events = (() => {
|
|||
const typ = TYPEN.find(t => t.id === ev.typ) || TYPEN[TYPEN.length - 1];
|
||||
const color = TYP_COLOR[ev.typ] || '#6b7280';
|
||||
const isOwn = _state.user?.id === ev.user_id;
|
||||
const isVdh = ev.quelle === 'vdh';
|
||||
|
||||
return `
|
||||
<div class="events-card" data-ev-id="${ev.id}" style="border-left-color:${color}">
|
||||
<div class="events-date-badge">
|
||||
|
|
@ -148,14 +177,22 @@ window.Page_events = (() => {
|
|||
<span class="month">${mon}</span>
|
||||
</div>
|
||||
<div class="events-card-body">
|
||||
<div class="events-card-title">${UI.escHtml(ev.titel)}</div>
|
||||
<div class="events-card-title">
|
||||
${UI.escHtml(ev.titel)}
|
||||
${isVdh ? `<span class="ev-vdh-badge" title="Vom VDH importiert">VDH</span>` : ''}
|
||||
</div>
|
||||
<div class="events-card-meta">
|
||||
<span class="events-badge" style="background:${color}20;color:${color}">${typ.icon} ${typ.label}</span>
|
||||
${ev.uhrzeit ? `· ${ev.uhrzeit} Uhr` : ''}
|
||||
${ev.ort_name ? `· 📍 ${UI.escHtml(ev.ort_name)}` : ''}
|
||||
${ev.uhrzeit ? `· ${_icon('clock')} ${ev.uhrzeit} Uhr` : ''}
|
||||
${ev.ort_name ? `· ${_icon('map-pin')} ${UI.escHtml(ev.ort_name)}` : ''}
|
||||
</div>
|
||||
${ev.link ? `<div class="events-card-actions">
|
||||
<a class="btn btn-ghost btn-xs ev-ext-link" href="${UI.escHtml(ev.link)}" target="_blank" rel="noopener" onclick="event.stopPropagation()">
|
||||
${_icon('arrow-square-out')} Details
|
||||
</a>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
${isOwn ? `<button class="btn-icon" data-ev-edit="${ev.id}" title="Bearbeiten">✏️</button>` : ''}
|
||||
${isOwn ? `<button class="btn-icon" data-ev-edit="${ev.id}" title="Bearbeiten">${_icon('pencil-simple')}</button>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
|
@ -223,22 +260,33 @@ window.Page_events = (() => {
|
|||
const d = new Date(ev.datum + 'T00:00:00');
|
||||
const datum = d.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
||||
const isOwn = _state.user?.id === ev.user_id;
|
||||
const isVdh = ev.quelle === 'vdh';
|
||||
|
||||
const body = `
|
||||
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3)">
|
||||
<span class="events-badge" style="background:${color}20;color:${color};font-size:var(--text-sm)">${typ.icon} ${typ.label}</span>
|
||||
${isVdh ? `<span class="ev-vdh-badge">VDH</span>` : ''}
|
||||
</div>
|
||||
<div class="events-detail-row">📅 ${datum}${ev.uhrzeit ? ' · ' + ev.uhrzeit + ' Uhr' : ''}</div>
|
||||
${ev.ort_name ? `<div class="events-detail-row">📍 ${UI.escHtml(ev.ort_name)}</div>` : ''}
|
||||
<div class="events-detail-row">${_icon('calendar-dots')} ${datum}${ev.uhrzeit ? ' · ' + ev.uhrzeit + ' Uhr' : ''}</div>
|
||||
${ev.ort_name ? `<div class="events-detail-row">${_icon('map-pin')} ${UI.escHtml(ev.ort_name)}</div>` : ''}
|
||||
${ev.beschreibung ? `<div class="events-detail-desc">${UI.escHtml(ev.beschreibung)}</div>` : ''}
|
||||
${ev.link ? `<div class="events-detail-row">🔗 <a href="${UI.escHtml(ev.link)}" target="_blank" rel="noopener">Mehr Infos</a></div>` : ''}
|
||||
<div class="events-detail-row" style="color:var(--c-text-muted);font-size:var(--text-xs)">Veranstalter: ${UI.escHtml(ev.veranstalter_name || '–')}</div>
|
||||
${ev.link ? `<div class="events-detail-row">
|
||||
${_icon('arrow-square-out')}
|
||||
<a href="${UI.escHtml(ev.link)}" target="_blank" rel="noopener">Mehr Infos</a>
|
||||
</div>` : ''}
|
||||
<div class="events-detail-row" style="color:var(--c-text-muted);font-size:var(--text-xs)">
|
||||
${_icon('user')} Veranstalter: ${UI.escHtml(ev.veranstalter_name || '–')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
const footer = isOwn ? `
|
||||
<button class="btn btn-secondary" id="ev-detail-edit">✏️ Bearbeiten</button>
|
||||
<button class="btn btn-danger" id="ev-detail-del">🗑️ Löschen</button>
|
||||
` : '';
|
||||
<button class="btn btn-secondary" id="ev-detail-edit">${_icon('pencil-simple')} Bearbeiten</button>
|
||||
<button class="btn btn-danger" id="ev-detail-del">${_icon('trash')} Löschen</button>
|
||||
` : (ev.link ? `
|
||||
<a class="btn btn-primary" href="${UI.escHtml(ev.link)}" target="_blank" rel="noopener">
|
||||
${_icon('arrow-square-out')} Zur Veranstaltung
|
||||
</a>
|
||||
` : '');
|
||||
|
||||
UI.modal.open({ title: UI.escHtml(ev.titel), body, footer });
|
||||
|
||||
|
|
@ -368,7 +416,16 @@ window.Page_events = (() => {
|
|||
// Click-Handler
|
||||
// ----------------------------------------------------------
|
||||
function _onClick(e) {
|
||||
// Filter
|
||||
// Quelle-Filter
|
||||
const sourceBtn = e.target.closest('[data-ev-quelle]');
|
||||
if (sourceBtn) {
|
||||
_quellFilter = sourceBtn.dataset.evQuelle;
|
||||
document.querySelectorAll('[data-ev-quelle]').forEach(b => b.classList.toggle('active', b.dataset.evQuelle === _quellFilter));
|
||||
_renderList();
|
||||
return;
|
||||
}
|
||||
|
||||
// Typ-Filter
|
||||
const filterBtn = e.target.closest('[data-ev-typ]');
|
||||
if (filterBtn) {
|
||||
_filter = filterBtn.dataset.evTyp;
|
||||
|
|
@ -387,8 +444,7 @@ window.Page_events = (() => {
|
|||
if (_view === 'karte') {
|
||||
listEl.style.display = 'none';
|
||||
mapEl.style.display = 'block';
|
||||
const filtered = _filter === 'alle' ? _events : _events.filter(ev => ev.typ === _filter);
|
||||
_renderMap(filtered);
|
||||
_renderMap(_filtered());
|
||||
} else {
|
||||
listEl.style.display = '';
|
||||
mapEl.style.display = 'none';
|
||||
|
|
@ -399,6 +455,9 @@ window.Page_events = (() => {
|
|||
// Neu-Button
|
||||
if (e.target.closest('#ev-new-btn')) { openNew(); return; }
|
||||
|
||||
// Externer Link — nicht als Karten-Klick behandeln
|
||||
if (e.target.closest('.ev-ext-link')) return;
|
||||
|
||||
// Bearbeiten-Icon auf Karte
|
||||
const editBtn = e.target.closest('[data-ev-edit]');
|
||||
if (editBtn) {
|
||||
|
|
|
|||
890
backend/static/js/pages/forum.js
Normal file
890
backend/static/js/pages/forum.js
Normal file
|
|
@ -0,0 +1,890 @@
|
|||
/* ============================================================
|
||||
BAN YARO — Forum (Sprint 11)
|
||||
Kategorien, Threads, Antworten, Likes, Reports,
|
||||
Foto-Upload, Mitgliederkarte, Moderations-Panel
|
||||
============================================================ */
|
||||
|
||||
window.Page_forum = (() => {
|
||||
|
||||
let _container = null;
|
||||
let _appState = null;
|
||||
let _threads = [];
|
||||
let _aktivKat = 'alle';
|
||||
let _offset = 0;
|
||||
let _searchTimer = null;
|
||||
let _searching = false;
|
||||
let _mapLoaded = false;
|
||||
let _leafletLoaded = false;
|
||||
let _map = null;
|
||||
let _activeSection = 'list'; // 'list' | 'map'
|
||||
|
||||
const LIMIT = 30;
|
||||
|
||||
const KATEGORIEN = [
|
||||
{ key: 'alle', label: 'Alle' },
|
||||
{ key: 'allgemein', label: 'Allgemein' },
|
||||
{ key: 'rasse', label: 'Rasse' },
|
||||
{ key: 'region', label: 'Region' },
|
||||
{ key: 'gesundheit', label: 'Gesundheit' },
|
||||
{ key: 'erziehung', label: 'Erziehung' },
|
||||
{ key: 'tauschboerse', label: 'Tauschbörse' },
|
||||
];
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Helpers
|
||||
// ----------------------------------------------------------
|
||||
function _esc(s) {
|
||||
return String(s || '').replace(/&/g,'&').replace(/</g,'<')
|
||||
.replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
function _fmtDate(iso) {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
const diff = (now - d) / 1000;
|
||||
if (diff < 60) return 'gerade eben';
|
||||
if (diff < 3600) return `vor ${Math.floor(diff / 60)} Min`;
|
||||
if (diff < 86400) return `vor ${Math.floor(diff / 3600)} Std`;
|
||||
return d.toLocaleDateString('de-DE');
|
||||
}
|
||||
|
||||
function _initial(name) {
|
||||
return (name || '?').charAt(0).toUpperCase();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// INIT
|
||||
// ----------------------------------------------------------
|
||||
async function init(container, appState) {
|
||||
_container = container;
|
||||
_appState = appState;
|
||||
_render();
|
||||
_loadThreads(true);
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
_loadThreads(true);
|
||||
}
|
||||
|
||||
function onDogChange() {}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// RENDER — Grundstruktur
|
||||
// ----------------------------------------------------------
|
||||
function _render() {
|
||||
const isMod = !!_appState.user?.is_moderator;
|
||||
|
||||
_container.innerHTML = `
|
||||
<div class="forum-layout">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="forum-header">
|
||||
<h2 class="forum-header-title">Forum</h2>
|
||||
<div class="forum-header-actions">
|
||||
${isMod ? `<button class="btn btn-ghost btn-sm" id="forum-mod-btn" title="Moderationsberichte">${UI.icon('warning')}</button>` : ''}
|
||||
<button class="btn btn-primary btn-sm" id="forum-new-btn">${UI.icon('plus')} Neues Thema</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kategorie-Tabs -->
|
||||
<div class="forum-category-tabs" id="forum-tabs">
|
||||
${KATEGORIEN.map(k => `
|
||||
<button class="forum-tab ${k.key === _aktivKat ? 'active' : ''}"
|
||||
data-kat="${k.key}">${_esc(k.label)}</button>
|
||||
`).join('')}
|
||||
<button class="forum-tab ${_activeSection === 'map' ? 'active' : ''}"
|
||||
data-section="map">${UI.icon('users')} Mitgliederkarte</button>
|
||||
</div>
|
||||
|
||||
<!-- Suchleiste -->
|
||||
<div class="forum-search-wrap">
|
||||
<input type="search" class="forum-search" id="forum-search"
|
||||
placeholder="Forum durchsuchen…" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<!-- Thread-Liste / Karte / Suche -->
|
||||
<div id="forum-main">
|
||||
<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Lädt…</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Tab-Klicks
|
||||
document.getElementById('forum-tabs').addEventListener('click', e => {
|
||||
const btn = e.target.closest('[data-kat], [data-section]');
|
||||
if (!btn) return;
|
||||
|
||||
if (btn.dataset.section === 'map') {
|
||||
_aktivKat = 'alle';
|
||||
_activeSection = 'map';
|
||||
document.querySelectorAll('.forum-tab').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
_renderMembersMap();
|
||||
return;
|
||||
}
|
||||
|
||||
_aktivKat = btn.dataset.kat;
|
||||
_activeSection = 'list';
|
||||
document.querySelectorAll('.forum-tab').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
_offset = 0;
|
||||
_threads = [];
|
||||
_loadThreads(true);
|
||||
});
|
||||
|
||||
// Suche
|
||||
document.getElementById('forum-search').addEventListener('input', e => {
|
||||
clearTimeout(_searchTimer);
|
||||
const q = e.target.value.trim();
|
||||
if (!q) {
|
||||
_searching = false;
|
||||
_renderList();
|
||||
return;
|
||||
}
|
||||
_searchTimer = setTimeout(() => _doSearch(q), 400);
|
||||
});
|
||||
|
||||
// Neues Thema
|
||||
document.getElementById('forum-new-btn').addEventListener('click', () => {
|
||||
if (!_appState.user) { UI.toast.info('Bitte erst anmelden.'); return; }
|
||||
_showCreateForm();
|
||||
});
|
||||
|
||||
// Moderations-Panel
|
||||
document.getElementById('forum-mod-btn')?.addEventListener('click', _showModPanel);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Threads laden
|
||||
// ----------------------------------------------------------
|
||||
async function _loadThreads(reset = false) {
|
||||
if (reset) { _offset = 0; _threads = []; }
|
||||
|
||||
const mainEl = document.getElementById('forum-main');
|
||||
if (mainEl && reset) {
|
||||
mainEl.innerHTML = `<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Lädt…</p>`;
|
||||
}
|
||||
|
||||
try {
|
||||
const params = { limit: LIMIT, offset: _offset };
|
||||
if (_aktivKat !== 'alle') params.kategorie = _aktivKat;
|
||||
|
||||
const rows = await API.forum.threads(params);
|
||||
_threads = reset ? rows : [..._threads, ...rows];
|
||||
_offset += rows.length;
|
||||
_renderList(rows.length < LIMIT);
|
||||
} catch (err) {
|
||||
UI.toast.error(err.message || 'Fehler beim Laden.');
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Thread-Liste rendern
|
||||
// ----------------------------------------------------------
|
||||
function _renderList(noMore = false) {
|
||||
if (_searching) return;
|
||||
const el = document.getElementById('forum-main');
|
||||
if (!el) return;
|
||||
|
||||
if (!_threads.length) {
|
||||
el.innerHTML = `
|
||||
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
|
||||
<div style="font-size:3rem;margin-bottom:var(--space-3)">${UI.icon('chat-circle-dots')}</div>
|
||||
<p style="color:var(--c-text-secondary)">Noch keine Beiträge in dieser Kategorie.</p>
|
||||
<button class="btn btn-primary" style="margin-top:var(--space-4)" id="forum-first-btn">
|
||||
Ersten Beitrag erstellen
|
||||
</button>
|
||||
</div>`;
|
||||
document.getElementById('forum-first-btn')?.addEventListener('click', () => {
|
||||
if (!_appState.user) { UI.toast.info('Bitte erst anmelden.'); return; }
|
||||
_showCreateForm();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="forum-list-inner" id="forum-thread-list">
|
||||
${_threads.map(_threadCardHTML).join('')}
|
||||
</div>
|
||||
${!noMore ? `<div style="text-align:center;padding:var(--space-4)">
|
||||
<button class="btn btn-secondary btn-sm" id="forum-loadmore">Mehr laden</button>
|
||||
</div>` : ''}
|
||||
`;
|
||||
|
||||
el.querySelectorAll('.forum-thread-card').forEach(card => {
|
||||
card.addEventListener('click', () => _openThread(parseInt(card.dataset.id)));
|
||||
});
|
||||
|
||||
document.getElementById('forum-loadmore')?.addEventListener('click', () => {
|
||||
_loadThreads(false);
|
||||
});
|
||||
}
|
||||
|
||||
function _threadCardHTML(t) {
|
||||
const preview = t.text_preview
|
||||
? _esc(t.text_preview.slice(0, 120)) + (t.text_preview.length >= 120 ? '…' : '')
|
||||
: '';
|
||||
const pinBadge = t.is_pinned ? `<span class="forum-pin-badge" title="Angepinnt">${UI.icon('push-pin')}</span>` : '';
|
||||
const lockBadge = t.is_locked ? `<span class="forum-lock-badge" title="Gesperrt">${UI.icon('lock')}</span>` : '';
|
||||
const fotoHtml = t.foto_preview
|
||||
? `<img class="forum-card-thumb" src="${_esc(t.foto_preview)}" alt="" loading="lazy">`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="forum-thread-card" data-id="${t.id}">
|
||||
<div class="forum-card-top">
|
||||
<span class="forum-category-badge forum-category-badge--${_esc(t.kategorie)}">${_esc(t.kategorie)}</span>
|
||||
${pinBadge}${lockBadge}
|
||||
</div>
|
||||
<div class="forum-card-content">
|
||||
<div class="forum-card-main">
|
||||
<div class="forum-card-title">${_esc(t.titel)}</div>
|
||||
${preview ? `<div class="forum-card-preview">${preview}</div>` : ''}
|
||||
<div class="forum-card-meta">
|
||||
<span>${UI.icon('user')} ${_esc(t.autor_name || 'Unbekannt')}</span>
|
||||
<span>${UI.icon('calendar-dots')} ${_fmtDate(t.created_at)}</span>
|
||||
<span>${UI.icon('chat-circle-dots')} ${t.antworten || 0}</span>
|
||||
<span class="${t.user_liked ? 'forum-liked' : ''}">${UI.icon('heart')} ${t.likes || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
${fotoHtml}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Suche
|
||||
// ----------------------------------------------------------
|
||||
async function _doSearch(q) {
|
||||
_searching = true;
|
||||
const el = document.getElementById('forum-main');
|
||||
if (el) el.innerHTML = `<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-6)">Suche…</p>`;
|
||||
|
||||
try {
|
||||
const results = await API.forum.search(q);
|
||||
if (!document.getElementById('forum-main')) return;
|
||||
if (!results.length) {
|
||||
document.getElementById('forum-main').innerHTML = `
|
||||
<div style="text-align:center;padding:var(--space-8)">
|
||||
<div style="font-size:2rem;margin-bottom:var(--space-2)">${UI.icon('magnifying-glass')}</div>
|
||||
<p style="color:var(--c-text-secondary)">Keine Ergebnisse für „${_esc(q)}"</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
document.getElementById('forum-main').innerHTML = `
|
||||
<div class="forum-list-inner">
|
||||
${results.map(t => _threadCardHTML({ ...t, foto_preview: null, is_pinned: 0, is_locked: 0, user_liked: false })).join('')}
|
||||
</div>`;
|
||||
document.getElementById('forum-main').querySelectorAll('.forum-thread-card').forEach(card => {
|
||||
card.addEventListener('click', () => _openThread(parseInt(card.dataset.id)));
|
||||
});
|
||||
} catch (err) {
|
||||
UI.toast.error(err.message || 'Suchfehler.');
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Thread-Detail-Modal
|
||||
// ----------------------------------------------------------
|
||||
async function _openThread(threadId) {
|
||||
let thread;
|
||||
try {
|
||||
thread = await API.forum.thread(threadId);
|
||||
} catch (err) {
|
||||
UI.toast.error(err.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const uid = _appState.user?.id;
|
||||
const isMod = !!_appState.user?.is_moderator;
|
||||
const isOwn = uid && uid === thread.user_id;
|
||||
|
||||
const modToolbar = (isMod) ? `
|
||||
<div class="forum-mod-toolbar">
|
||||
<button class="btn btn-ghost btn-sm forum-mod-pin" title="${thread.is_pinned ? 'Unpin' : 'Anpinnen'}">
|
||||
${UI.icon('push-pin')} ${thread.is_pinned ? 'Unpin' : 'Pin'}
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm forum-mod-lock" title="${thread.is_locked ? 'Entsperren' : 'Sperren'}">
|
||||
${UI.icon('lock')} ${thread.is_locked ? 'Entsperren' : 'Sperren'}
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm forum-mod-delete-thread" style="color:var(--c-danger)">${UI.icon('trash')} Thread</button>
|
||||
</div>` : '';
|
||||
|
||||
const fotoGallery = (thread.foto_urls?.length)
|
||||
? `<div class="forum-foto-grid">${thread.foto_urls.map(u =>
|
||||
`<img src="${_esc(u)}" class="forum-foto-img" data-src="${_esc(u)}" alt="" loading="lazy">`
|
||||
).join('')}</div>`
|
||||
: '';
|
||||
|
||||
const likeClass = thread.user_liked ? 'forum-like-btn active' : 'forum-like-btn';
|
||||
|
||||
const postsHtml = (thread.posts?.length)
|
||||
? thread.posts.map(p => _postHTML(p, uid, isMod)).join('')
|
||||
: `<p style="color:var(--c-text-muted);font-style:italic;padding:var(--space-3) 0">Noch keine Antworten.</p>`;
|
||||
|
||||
const replySection = _appState.user && !thread.is_locked ? `
|
||||
<div class="forum-reply-form">
|
||||
<label class="form-label">Antwort schreiben</label>
|
||||
<textarea class="form-control" id="forum-reply-text" rows="3"
|
||||
placeholder="Deine Antwort…"></textarea>
|
||||
<div class="forum-reply-actions">
|
||||
<label class="btn btn-ghost btn-sm forum-upload-label" title="Foto anhängen">
|
||||
${UI.icon('camera')}
|
||||
<input type="file" accept="image/*" id="forum-reply-file" style="display:none">
|
||||
</label>
|
||||
<div id="forum-reply-previews" class="forum-upload-previews"></div>
|
||||
</div>
|
||||
</div>` : (!_appState.user
|
||||
? `<p style="color:var(--c-text-muted);font-size:0.85rem;margin-top:var(--space-3)">Bitte anmelden um zu antworten.</p>`
|
||||
: `<p style="color:var(--c-text-muted);font-size:0.85rem;margin-top:var(--space-3)">${UI.icon('lock')} Dieser Thread ist gesperrt.</p>`
|
||||
);
|
||||
|
||||
const body = `
|
||||
<div class="forum-thread-detail">
|
||||
${modToolbar}
|
||||
<div class="forum-thread-header-row">
|
||||
<span class="forum-category-badge forum-category-badge--${_esc(thread.kategorie)}">${_esc(thread.kategorie)}</span>
|
||||
<span style="color:var(--c-text-muted);font-size:0.8rem">${_fmtDate(thread.created_at)}</span>
|
||||
${thread.is_pinned ? `<span>${UI.icon('push-pin')}</span>` : ''}
|
||||
${thread.is_locked ? `<span>${UI.icon('lock')}</span>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="forum-thread-body">
|
||||
<p style="white-space:pre-wrap;word-break:break-word">${_esc(thread.text)}</p>
|
||||
${fotoGallery}
|
||||
</div>
|
||||
|
||||
<div class="forum-thread-author-row">
|
||||
<div class="forum-avatar">${_esc(_initial(thread.autor_name))}</div>
|
||||
<span style="font-size:0.85rem;color:var(--c-text-secondary)">${_esc(thread.autor_name || 'Unbekannt')}</span>
|
||||
<div style="margin-left:auto;display:flex;gap:var(--space-2);align-items:center">
|
||||
<button class="${likeClass}" id="thread-like-btn" data-count="${thread.likes || 0}">
|
||||
${UI.icon('heart')} <span id="thread-like-count">${thread.likes || 0}</span>
|
||||
</button>
|
||||
${_appState.user && !isOwn ? `<button class="forum-report-btn" id="thread-report-btn">${UI.icon('flag')}</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="forum-posts-section">
|
||||
<div class="forum-posts-divider">${thread.antworten || 0} Antworten</div>
|
||||
<div id="forum-posts-list">${postsHtml}</div>
|
||||
</div>
|
||||
|
||||
${replySection}
|
||||
</div>
|
||||
`;
|
||||
|
||||
const footer = _appState.user ? `
|
||||
${(isOwn || isMod) ? `<button type="button" class="btn btn-ghost btn-sm" id="ft-delete-thread" style="color:var(--c-danger)">${UI.icon('trash')} Löschen</button>` : ''}
|
||||
<button type="button" class="btn btn-secondary" id="ft-close">Schließen</button>
|
||||
${(!thread.is_locked && _appState.user) ? `<button type="button" class="btn btn-primary" id="ft-reply">Antworten</button>` : ''}
|
||||
` : `<button type="button" class="btn btn-primary" id="ft-close">Schließen</button>`;
|
||||
|
||||
UI.modal.open({ title: `${UI.icon('chat-circle-dots')} ${_esc(thread.titel)}`, body, footer });
|
||||
|
||||
// Close
|
||||
document.getElementById('ft-close')?.addEventListener('click', UI.modal.close);
|
||||
|
||||
// Like thread
|
||||
document.getElementById('thread-like-btn')?.addEventListener('click', async () => {
|
||||
if (!_appState.user) { UI.toast.info('Bitte erst anmelden.'); return; }
|
||||
const btn = document.getElementById('thread-like-btn');
|
||||
try {
|
||||
const res = await API.forum.like('thread', thread.id);
|
||||
thread.user_liked = res.liked;
|
||||
thread.likes = res.count;
|
||||
btn.classList.toggle('active', res.liked);
|
||||
const countEl = document.getElementById('thread-like-count');
|
||||
if (countEl) countEl.textContent = res.count;
|
||||
// Update local state
|
||||
const idx = _threads.findIndex(t => t.id === thread.id);
|
||||
if (idx !== -1) { _threads[idx].likes = res.count; _threads[idx].user_liked = res.liked; }
|
||||
} catch (err) { UI.toast.error(err.message); }
|
||||
});
|
||||
|
||||
// Report thread
|
||||
document.getElementById('thread-report-btn')?.addEventListener('click', () => {
|
||||
_showReportForm('thread', thread.id);
|
||||
});
|
||||
|
||||
// Delete thread
|
||||
document.getElementById('ft-delete-thread')?.addEventListener('click', async () => {
|
||||
const ok = await UI.modal.confirm({
|
||||
title: 'Thread löschen?',
|
||||
message: 'Der Thread wird unwiderruflich entfernt.',
|
||||
confirmText: 'Löschen', danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await API.forum.deleteThread(thread.id);
|
||||
_threads = _threads.filter(t => t.id !== thread.id);
|
||||
UI.modal.close();
|
||||
_renderList(true);
|
||||
UI.toast.success('Thread gelöscht.');
|
||||
} catch (err) { UI.toast.error(err.message); }
|
||||
});
|
||||
|
||||
// Moderator: pin/lock/delete
|
||||
document.querySelector('.forum-mod-pin')?.addEventListener('click', async () => {
|
||||
try {
|
||||
await API.forum.patchThread(thread.id, { is_pinned: thread.is_pinned ? 0 : 1 });
|
||||
UI.toast.success('Gespeichert.');
|
||||
UI.modal.close();
|
||||
_loadThreads(true);
|
||||
} catch (err) { UI.toast.error(err.message); }
|
||||
});
|
||||
|
||||
document.querySelector('.forum-mod-lock')?.addEventListener('click', async () => {
|
||||
try {
|
||||
await API.forum.patchThread(thread.id, { is_locked: thread.is_locked ? 0 : 1 });
|
||||
UI.toast.success('Gespeichert.');
|
||||
UI.modal.close();
|
||||
_loadThreads(true);
|
||||
} catch (err) { UI.toast.error(err.message); }
|
||||
});
|
||||
|
||||
document.querySelector('.forum-mod-delete-thread')?.addEventListener('click', async () => {
|
||||
const ok = await UI.modal.confirm({ title: 'Thread löschen?', message: 'Moderator-Löschung.', confirmText: 'Löschen', danger: true });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await API.forum.deleteThread(thread.id);
|
||||
_threads = _threads.filter(t => t.id !== thread.id);
|
||||
UI.modal.close();
|
||||
_renderList(true);
|
||||
UI.toast.success('Thread gelöscht.');
|
||||
} catch (err) { UI.toast.error(err.message); }
|
||||
});
|
||||
|
||||
// Foto-Vollbild
|
||||
document.getElementById('modal-container')?.querySelectorAll('.forum-foto-img').forEach(img => {
|
||||
img.addEventListener('click', () => {
|
||||
window.open(img.dataset.src || img.src, '_blank');
|
||||
});
|
||||
});
|
||||
|
||||
// Reply file preview
|
||||
const replyFileInput = document.getElementById('forum-reply-file');
|
||||
replyFileInput?.addEventListener('change', () => {
|
||||
const previews = document.getElementById('forum-reply-previews');
|
||||
if (!previews) return;
|
||||
previews.innerHTML = '';
|
||||
const files = Array.from(replyFileInput.files || []);
|
||||
files.slice(0, 5).forEach(file => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = document.createElement('img');
|
||||
img.src = url;
|
||||
img.className = 'forum-upload-thumb';
|
||||
previews.appendChild(img);
|
||||
});
|
||||
});
|
||||
|
||||
// Post-Löschen-Buttons binden
|
||||
const postsListEl = document.getElementById('forum-posts-list');
|
||||
if (postsListEl) _bindPostActions(postsListEl, thread.id, uid, isMod);
|
||||
|
||||
// Reply abschicken
|
||||
document.getElementById('ft-reply')?.addEventListener('click', async () => {
|
||||
const btn = document.getElementById('ft-reply');
|
||||
const text = document.getElementById('forum-reply-text')?.value?.trim();
|
||||
if (!text) { UI.toast.warning('Bitte Text eingeben.'); return; }
|
||||
|
||||
await UI.asyncButton(btn, async () => {
|
||||
const post = await API.forum.addPost(thread.id, { text });
|
||||
|
||||
// Foto hochladen falls vorhanden
|
||||
const files = Array.from(document.getElementById('forum-reply-file')?.files || []);
|
||||
for (const file of files.slice(0, 5)) {
|
||||
try {
|
||||
await API.forum.uploadPostFoto(post.id, file);
|
||||
} catch (e) { /* Foto-Upload-Fehler ignorieren */ }
|
||||
}
|
||||
|
||||
thread.antworten = (thread.antworten || 0) + 1;
|
||||
const idx = _threads.findIndex(t => t.id === thread.id);
|
||||
if (idx !== -1) _threads[idx].antworten = thread.antworten;
|
||||
|
||||
const listEl = document.getElementById('forum-posts-list');
|
||||
if (listEl) {
|
||||
const placeholder = listEl.querySelector('p[style*="italic"]');
|
||||
if (placeholder) listEl.innerHTML = '';
|
||||
listEl.insertAdjacentHTML('beforeend', _postHTML(post, uid, isMod));
|
||||
_bindPostActions(listEl, thread.id, uid, isMod);
|
||||
}
|
||||
document.getElementById('forum-reply-text').value = '';
|
||||
const previews = document.getElementById('forum-reply-previews');
|
||||
if (previews) previews.innerHTML = '';
|
||||
UI.toast.success('Antwort gesendet.');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Post HTML
|
||||
// ----------------------------------------------------------
|
||||
function _postHTML(p, uid, isMod) {
|
||||
if (p.is_deleted) {
|
||||
return `<div class="forum-post forum-post--deleted" data-post-id="${p.id}">
|
||||
<em>Beitrag wurde entfernt</em>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const isOwn = uid && uid === p.user_id;
|
||||
const fotoHtml = (p.foto_urls?.length)
|
||||
? `<div class="forum-foto-grid">${p.foto_urls.map(u =>
|
||||
`<img src="${_esc(u)}" class="forum-foto-img" data-src="${_esc(u)}" alt="" loading="lazy">`
|
||||
).join('')}</div>`
|
||||
: '';
|
||||
|
||||
const likeClass = p.user_liked ? 'forum-like-btn active' : 'forum-like-btn';
|
||||
const canDelete = isOwn || isMod;
|
||||
|
||||
return `
|
||||
<div class="forum-post" data-post-id="${p.id}" data-user-id="${p.user_id || ''}">
|
||||
<div class="forum-post-header">
|
||||
<div class="forum-avatar forum-avatar--sm">${_esc(_initial(p.autor_name))}</div>
|
||||
<span class="forum-post-author">${_esc(p.autor_name || 'Unbekannt')}</span>
|
||||
<span class="forum-post-date">${_fmtDate(p.created_at)}</span>
|
||||
</div>
|
||||
<div class="forum-post-body">
|
||||
<div class="forum-post-text">${_esc(p.text)}</div>
|
||||
${fotoHtml}
|
||||
</div>
|
||||
<div class="forum-post-actions">
|
||||
<button class="${likeClass} forum-post-like" data-post-id="${p.id}">
|
||||
${UI.icon('heart')} <span class="forum-post-like-count">${p.likes || 0}</span>
|
||||
</button>
|
||||
${(!isOwn && uid) ? `<button class="forum-report-btn forum-post-report" data-post-id="${p.id}">${UI.icon('flag')}</button>` : ''}
|
||||
${canDelete ? `<button class="btn btn-ghost btn-sm forum-post-delete" data-post-id="${p.id}" style="color:var(--c-danger);margin-left:auto">${UI.icon('trash')}</button>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Post-Aktionen binden
|
||||
// ----------------------------------------------------------
|
||||
function _bindPostActions(container, threadId, uid, isMod) {
|
||||
// Like
|
||||
container.querySelectorAll('.forum-post-like:not([data-bound])').forEach(btn => {
|
||||
btn.dataset.bound = '1';
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!uid) { UI.toast.info('Bitte erst anmelden.'); return; }
|
||||
const postId = parseInt(btn.dataset.postId);
|
||||
try {
|
||||
const res = await API.forum.like('post', postId);
|
||||
btn.classList.toggle('active', res.liked);
|
||||
const countEl = btn.querySelector('.forum-post-like-count');
|
||||
if (countEl) countEl.textContent = res.count;
|
||||
} catch (err) { UI.toast.error(err.message); }
|
||||
});
|
||||
});
|
||||
|
||||
// Report
|
||||
container.querySelectorAll('.forum-post-report:not([data-bound])').forEach(btn => {
|
||||
btn.dataset.bound = '1';
|
||||
btn.addEventListener('click', () => {
|
||||
_showReportForm('post', parseInt(btn.dataset.postId));
|
||||
});
|
||||
});
|
||||
|
||||
// Delete
|
||||
container.querySelectorAll('.forum-post-delete:not([data-bound])').forEach(btn => {
|
||||
btn.dataset.bound = '1';
|
||||
btn.addEventListener('click', async () => {
|
||||
const postId = parseInt(btn.dataset.postId);
|
||||
const postEl = container.querySelector(`[data-post-id="${postId}"]`);
|
||||
const ok = await UI.modal.confirm({
|
||||
title: 'Antwort löschen?',
|
||||
message: 'Dieser Beitrag wird entfernt.',
|
||||
confirmText: 'Löschen', danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await API.forum.deletePost(postId);
|
||||
if (postEl) {
|
||||
postEl.innerHTML = '<em style="color:var(--c-text-muted)">Beitrag wurde entfernt</em>';
|
||||
postEl.className = 'forum-post forum-post--deleted';
|
||||
}
|
||||
const idx = _threads.findIndex(t => t.id === threadId);
|
||||
if (idx !== -1 && _threads[idx].antworten > 0) _threads[idx].antworten--;
|
||||
UI.toast.success('Beitrag gelöscht.');
|
||||
} catch (err) { UI.toast.error(err.message); }
|
||||
});
|
||||
});
|
||||
|
||||
// Foto-Fullscreen
|
||||
container.querySelectorAll('.forum-foto-img:not([data-bound])').forEach(img => {
|
||||
img.dataset.bound = '1';
|
||||
img.addEventListener('click', () => window.open(img.dataset.src || img.src, '_blank'));
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Report-Formular
|
||||
// ----------------------------------------------------------
|
||||
function _showReportForm(targetType, targetId) {
|
||||
const body = `
|
||||
<form id="forum-report-form">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Grund der Meldung</label>
|
||||
<select class="form-control" name="grund">
|
||||
<option value="spam">Spam</option>
|
||||
<option value="beleidigung">Beleidigung / Hassrede</option>
|
||||
<option value="falschinfo">Falsche Informationen</option>
|
||||
<option value="sonstiges">Sonstiges</option>
|
||||
</select>
|
||||
</div>
|
||||
</form>`;
|
||||
const footer = `
|
||||
<button type="button" class="btn btn-secondary flex-1" id="rep-cancel">Abbrechen</button>
|
||||
<button type="submit" form="forum-report-form" class="btn btn-danger flex-1">Melden</button>`;
|
||||
|
||||
UI.modal.open({ title: `${UI.icon('flag')} Inhalt melden`, body, footer });
|
||||
document.getElementById('rep-cancel')?.addEventListener('click', UI.modal.close);
|
||||
|
||||
document.getElementById('forum-report-form')?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const btn = document.querySelector('[form="forum-report-form"][type="submit"]');
|
||||
const fd = UI.formData(e.target);
|
||||
await UI.asyncButton(btn, async () => {
|
||||
await API.forum.report(targetType, targetId, fd.grund);
|
||||
UI.modal.close();
|
||||
UI.toast.success('Gemeldet. Danke!');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Neues Thema
|
||||
// ----------------------------------------------------------
|
||||
function _showCreateForm() {
|
||||
const katOptions = KATEGORIEN.filter(k => k.key !== 'alle').map(k =>
|
||||
`<option value="${k.key}" ${k.key === _aktivKat ? 'selected' : ''}>${_esc(k.label)}</option>`
|
||||
).join('');
|
||||
|
||||
const body = `
|
||||
<form id="forum-thread-form" autocomplete="off">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Kategorie</label>
|
||||
<select class="form-control" name="kategorie">${katOptions}</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Titel *</label>
|
||||
<input class="form-control" type="text" name="titel"
|
||||
placeholder="Worum geht es?" required maxlength="200">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Text * (min. 20 Zeichen)</label>
|
||||
<textarea class="form-control" name="text" rows="5"
|
||||
placeholder="Beschreibe dein Thema ausführlich…" required></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Fotos (max. 5)</label>
|
||||
<div class="forum-upload-area">
|
||||
<label class="btn btn-secondary btn-sm" for="forum-thread-files">${UI.icon('camera')} Fotos auswählen</label>
|
||||
<input type="file" id="forum-thread-files" accept="image/*" multiple style="display:none">
|
||||
</div>
|
||||
<div id="forum-thread-previews" class="forum-upload-previews" style="margin-top:var(--space-2)"></div>
|
||||
</div>
|
||||
</form>`;
|
||||
|
||||
const footer = `
|
||||
<button type="button" class="btn btn-secondary flex-1" id="ff-cancel">Abbrechen</button>
|
||||
<button type="submit" form="forum-thread-form" class="btn btn-primary flex-1">${UI.icon('chat-circle-dots')} Erstellen</button>`;
|
||||
|
||||
UI.modal.open({ title: '+ Neues Thema', body, footer });
|
||||
|
||||
document.getElementById('ff-cancel')?.addEventListener('click', UI.modal.close);
|
||||
|
||||
// Foto-Vorschau
|
||||
document.getElementById('forum-thread-files')?.addEventListener('change', e => {
|
||||
const previews = document.getElementById('forum-thread-previews');
|
||||
if (!previews) return;
|
||||
previews.innerHTML = '';
|
||||
Array.from(e.target.files || []).slice(0, 5).forEach(file => {
|
||||
const img = document.createElement('img');
|
||||
img.src = URL.createObjectURL(file);
|
||||
img.className = 'forum-upload-thumb';
|
||||
previews.appendChild(img);
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('forum-thread-form')?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const btn = document.querySelector('[form="forum-thread-form"][type="submit"]');
|
||||
const fd = UI.formData(e.target);
|
||||
|
||||
if ((fd.text || '').trim().length < 20) {
|
||||
UI.toast.warning('Text muss mindestens 20 Zeichen lang sein.');
|
||||
return;
|
||||
}
|
||||
|
||||
await UI.asyncButton(btn, async () => {
|
||||
const created = await API.forum.create({
|
||||
kategorie: fd.kategorie,
|
||||
titel: (fd.titel || '').trim(),
|
||||
text: (fd.text || '').trim(),
|
||||
});
|
||||
|
||||
// Fotos hochladen
|
||||
const files = Array.from(document.getElementById('forum-thread-files')?.files || []);
|
||||
for (const file of files.slice(0, 5)) {
|
||||
try {
|
||||
await API.forum.uploadThreadFoto(created.id, file);
|
||||
} catch (e) { /* ignorieren */ }
|
||||
}
|
||||
|
||||
_threads.unshift({
|
||||
...created,
|
||||
text_preview: created.text?.slice(0, 120) || '',
|
||||
foto_preview: null,
|
||||
});
|
||||
UI.modal.close();
|
||||
_renderList();
|
||||
UI.toast.success('Thema erstellt!');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Mitgliederkarte
|
||||
// ----------------------------------------------------------
|
||||
async function _renderMembersMap() {
|
||||
const el = document.getElementById('forum-main');
|
||||
if (!el) return;
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="forum-members-section">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-3)">
|
||||
<strong>Mitglieder auf der Karte</strong>
|
||||
${_appState.user ? `
|
||||
<label class="forum-location-toggle">
|
||||
<input type="checkbox" id="forum-loc-toggle" ${_appState.user.forum_show_location ? 'checked' : ''}>
|
||||
<span>Meinen (ungefähren) Standort teilen</span>
|
||||
</label>` : ''}
|
||||
</div>
|
||||
<div id="forum-map" class="forum-map-container"></div>
|
||||
</div>`;
|
||||
|
||||
// Location-Toggle
|
||||
document.getElementById('forum-loc-toggle')?.addEventListener('change', async e => {
|
||||
const show = e.target.checked;
|
||||
if (show) {
|
||||
try {
|
||||
const pos = await API.getLocation();
|
||||
await API.forum.setLocation(pos.lat, pos.lon, true);
|
||||
UI.toast.success('Standort geteilt.');
|
||||
_loadMembersOnMap();
|
||||
} catch (err) {
|
||||
e.target.checked = false;
|
||||
UI.toast.error('Standort konnte nicht ermittelt werden.');
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await API.forum.setLocation(null, null, false);
|
||||
UI.toast.success('Standort versteckt.');
|
||||
} catch (err) { UI.toast.error(err.message); }
|
||||
}
|
||||
});
|
||||
|
||||
await _loadLeaflet();
|
||||
const mapEl = document.getElementById('forum-map');
|
||||
if (!mapEl) return;
|
||||
|
||||
_map = L.map(mapEl, { zoomControl: true }).setView([51.0, 10.0], 6);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap',
|
||||
maxZoom: 18,
|
||||
}).addTo(_map);
|
||||
|
||||
_loadMembersOnMap();
|
||||
}
|
||||
|
||||
async function _loadMembersOnMap() {
|
||||
if (!_map) return;
|
||||
try {
|
||||
const members = await API.forum.membersMap();
|
||||
members.forEach(m => {
|
||||
L.marker([m.lat, m.lon])
|
||||
.bindPopup(`<strong>${_esc(m.vorname || '?')}</strong>`)
|
||||
.addTo(_map);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Mitgliederkarte Fehler:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function _loadLeaflet() {
|
||||
if (_leafletLoaded || window.L) { _leafletLoaded = true; return; }
|
||||
|
||||
// CSS
|
||||
if (!document.querySelector('link[href*="leaflet.css"]')) {
|
||||
const lCss = document.createElement('link');
|
||||
lCss.rel = 'stylesheet';
|
||||
lCss.href = '/css/leaflet.css';
|
||||
document.head.appendChild(lCss);
|
||||
}
|
||||
|
||||
// JS
|
||||
await new Promise((resolve, reject) => {
|
||||
if (window.L) { resolve(); return; }
|
||||
const s = document.createElement('script');
|
||||
s.src = '/js/leaflet.js';
|
||||
s.onload = resolve;
|
||||
s.onerror = reject;
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
|
||||
_leafletLoaded = true;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Moderations-Panel
|
||||
// ----------------------------------------------------------
|
||||
async function _showModPanel() {
|
||||
let reports;
|
||||
try {
|
||||
reports = await API.forum.reports();
|
||||
} catch (err) {
|
||||
UI.toast.error(err.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const body = reports.length
|
||||
? `<div class="forum-mod-reports">
|
||||
${reports.map(r => `
|
||||
<div class="forum-mod-report-item" data-id="${r.id}">
|
||||
<div style="font-size:var(--text-sm)">
|
||||
<strong>${_esc(r.target_type)} #${r.target_id}</strong>
|
||||
— ${_esc(r.grund)}
|
||||
</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">
|
||||
von ${_esc(r.melder_name || '?')} · ${_fmtDate(r.created_at)}
|
||||
</div>
|
||||
<button class="btn btn-sm btn-secondary forum-resolve-btn" data-id="${r.id}" style="margin-top:var(--space-2)">
|
||||
${UI.icon('check')} Erledigt
|
||||
</button>
|
||||
</div>`).join('')}
|
||||
</div>`
|
||||
: `<p style="color:var(--c-text-muted);text-align:center;padding:var(--space-6)">Keine offenen Berichte.</p>`;
|
||||
|
||||
const footer = `<button type="button" class="btn btn-primary flex-1" id="mod-close">Schließen</button>`;
|
||||
UI.modal.open({ title: `${UI.icon('scales')} Moderationsberichte`, body, footer });
|
||||
document.getElementById('mod-close')?.addEventListener('click', UI.modal.close);
|
||||
|
||||
document.querySelectorAll('.forum-resolve-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const id = parseInt(btn.dataset.id);
|
||||
try {
|
||||
await API.forum.resolveReport(id);
|
||||
btn.closest('.forum-mod-report-item')?.remove();
|
||||
UI.toast.success('Erledigt.');
|
||||
} catch (err) { UI.toast.error(err.message); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return { init, refresh, onDogChange };
|
||||
|
||||
})();
|
||||
282
backend/static/js/pages/friends.js
Normal file
282
backend/static/js/pages/friends.js
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
/* ============================================================
|
||||
BAN YARO — Freunde-Seite
|
||||
============================================================ */
|
||||
|
||||
window.Page_friends = (() => {
|
||||
|
||||
let _container = null;
|
||||
let _searchTimer = null;
|
||||
|
||||
// ----------------------------------------------------------
|
||||
function init(container) {
|
||||
_container = container;
|
||||
render();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
async function render() {
|
||||
_container.innerHTML = `
|
||||
<div style="padding:var(--space-4)">
|
||||
<h2 style="font-size:var(--text-xl);font-weight:var(--weight-bold);margin-bottom:var(--space-4)">
|
||||
Freunde
|
||||
</h2>
|
||||
|
||||
<!-- Suche -->
|
||||
<div class="friends-search-row">
|
||||
<input id="fr-search" class="form-input" type="search"
|
||||
placeholder="Namen suchen…" autocomplete="off" style="flex:1">
|
||||
</div>
|
||||
<div id="fr-search-results"></div>
|
||||
|
||||
<!-- Incoming requests -->
|
||||
<div id="fr-incoming"></div>
|
||||
|
||||
<!-- Outgoing -->
|
||||
<div id="fr-outgoing"></div>
|
||||
|
||||
<!-- Friends list -->
|
||||
<div id="fr-list"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('fr-search').addEventListener('input', e => {
|
||||
clearTimeout(_searchTimer);
|
||||
_searchTimer = setTimeout(() => _doSearch(e.target.value.trim()), 400);
|
||||
});
|
||||
|
||||
await _loadFriends();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
async function _loadFriends() {
|
||||
try {
|
||||
const data = await API.friends.list();
|
||||
_renderIncoming(data.incoming);
|
||||
_renderOutgoing(data.outgoing);
|
||||
_renderFriends(data.friends);
|
||||
_updateBadge(data.incoming.length);
|
||||
} catch (e) {
|
||||
if (e.status === 401) {
|
||||
document.getElementById('fr-list').innerHTML =
|
||||
`<div class="empty-state"><p>Bitte melde dich an, um Freunde zu verwalten.</p></div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
function _updateBadge(count) {
|
||||
const el = document.getElementById('friends-badge');
|
||||
if (!el) return;
|
||||
el.textContent = count;
|
||||
el.style.display = count > 0 ? '' : 'none';
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
function _renderIncoming(list) {
|
||||
const el = document.getElementById('fr-incoming');
|
||||
if (!list.length) { el.innerHTML = ''; return; }
|
||||
el.innerHTML = `
|
||||
<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||||
color:var(--c-text-secondary);margin-bottom:var(--space-2)">
|
||||
Anfragen (${list.length})
|
||||
</h3>
|
||||
${list.map(r => `
|
||||
<div class="friend-request-card">
|
||||
<div class="friend-avatar">${_initial(r.requester_name)}</div>
|
||||
<div style="flex:1">
|
||||
<div style="font-weight:var(--weight-semibold)">${_esc(r.requester_name)}</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">möchte mit dir befreundet sein</div>
|
||||
</div>
|
||||
<div class="friend-item-actions">
|
||||
<button class="btn btn-primary btn-sm"
|
||||
onclick="Page_friends._accept(${r.id})">
|
||||
<svg class="ph-icon"><use href="/icons/phosphor.svg#check"></use></svg>
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm"
|
||||
onclick="Page_friends._decline(${r.id})">
|
||||
<svg class="ph-icon"><use href="/icons/phosphor.svg#x"></use></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
function _renderOutgoing(list) {
|
||||
const el = document.getElementById('fr-outgoing');
|
||||
if (!list.length) { el.innerHTML = ''; return; }
|
||||
el.innerHTML = `
|
||||
<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||||
color:var(--c-text-secondary);margin:var(--space-4) 0 var(--space-2)">
|
||||
Gesendete Anfragen
|
||||
</h3>
|
||||
${list.map(r => `
|
||||
<div class="friend-item">
|
||||
<div class="friend-avatar">${_initial(r.addressee_name)}</div>
|
||||
<div class="friend-item-name">${_esc(r.addressee_name)}</div>
|
||||
<span style="font-size:var(--text-xs);color:var(--c-text-muted)">ausstehend</span>
|
||||
<button class="btn btn-ghost btn-sm"
|
||||
onclick="Page_friends._cancel(${r.id})"
|
||||
title="Anfrage zurückziehen">
|
||||
<svg class="ph-icon"><use href="/icons/phosphor.svg#x"></use></svg>
|
||||
</button>
|
||||
</div>
|
||||
`).join('')}
|
||||
`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
function _renderFriends(list) {
|
||||
const el = document.getElementById('fr-list');
|
||||
if (!list.length) {
|
||||
el.innerHTML = `
|
||||
<div class="empty-state" style="margin-top:var(--space-6)">
|
||||
<svg class="ph-icon" style="font-size:3rem;opacity:0.3"><use href="/icons/phosphor.svg#users"></use></svg>
|
||||
<p style="margin-top:var(--space-3);color:var(--c-text-muted)">
|
||||
Noch keine Freunde. Suche oben nach Nutzern!
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
el.innerHTML = `
|
||||
<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||||
color:var(--c-text-secondary);margin:var(--space-4) 0 var(--space-2)">
|
||||
Freunde (${list.length})
|
||||
</h3>
|
||||
${list.map(f => `
|
||||
<div class="friend-item">
|
||||
<div class="friend-avatar">${_initial(f.friend_name)}</div>
|
||||
<div class="friend-item-name">${_esc(f.friend_name)}</div>
|
||||
<div class="friend-item-actions">
|
||||
<button class="btn btn-ghost btn-sm"
|
||||
onclick="Page_friends._openChat(${f.friend_id})"
|
||||
title="Nachricht senden">
|
||||
<svg class="ph-icon"><use href="/icons/phosphor.svg#chat-circle-dots"></use></svg>
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm"
|
||||
onclick="Page_friends._removeFriend(${f.friend_id}, '${_esc(f.friend_name)}')"
|
||||
title="Freund entfernen">
|
||||
<svg class="ph-icon"><use href="/icons/phosphor.svg#user-minus"></use></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
async function _doSearch(q) {
|
||||
const el = document.getElementById('fr-search-results');
|
||||
if (q.length < 2) { el.innerHTML = ''; return; }
|
||||
try {
|
||||
const results = await API.friends.search(q);
|
||||
if (!results.length) {
|
||||
el.innerHTML = `<div class="friends-search-results">
|
||||
<div style="padding:var(--space-3) var(--space-4);color:var(--c-text-muted);font-size:var(--text-sm)">
|
||||
Keine Nutzer gefunden.
|
||||
</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
el.innerHTML = `
|
||||
<div class="friends-search-results">
|
||||
${results.map(u => `
|
||||
<div class="friend-result-item">
|
||||
<div class="friend-avatar">${_initial(u.name)}</div>
|
||||
<div style="flex:1;font-size:var(--text-sm)">${_esc(u.name)}</div>
|
||||
<button class="btn btn-primary btn-sm"
|
||||
onclick="Page_friends._sendRequest(${u.id}, this)">
|
||||
<svg class="ph-icon"><use href="/icons/phosphor.svg#user-plus"></use></svg>
|
||||
Anfrage
|
||||
</button>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
} catch (e) {
|
||||
el.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
async function _sendRequest(userId, btn) {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await API.friends.sendRequest(userId);
|
||||
UI.toast('Freundschaftsanfrage gesendet!', 'success');
|
||||
document.getElementById('fr-search').value = '';
|
||||
document.getElementById('fr-search-results').innerHTML = '';
|
||||
await _loadFriends();
|
||||
} catch (e) {
|
||||
UI.toast(e.message, 'danger');
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function _accept(id) {
|
||||
try {
|
||||
await API.friends.accept(id);
|
||||
UI.toast('Freundschaft angenommen!', 'success');
|
||||
await _loadFriends();
|
||||
} catch (e) {
|
||||
UI.toast(e.message, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function _decline(id) {
|
||||
try {
|
||||
await API.friends.decline(id);
|
||||
await _loadFriends();
|
||||
} catch (e) {
|
||||
UI.toast(e.message, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function _cancel(id) {
|
||||
try {
|
||||
await API.friends.decline(id);
|
||||
await _loadFriends();
|
||||
} catch (e) {
|
||||
UI.toast(e.message, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function _removeFriend(userId, name) {
|
||||
if (!confirm(`${name} als Freund entfernen?`)) return;
|
||||
try {
|
||||
await API.friends.remove(userId);
|
||||
UI.toast('Freund entfernt.', 'info');
|
||||
await _loadFriends();
|
||||
} catch (e) {
|
||||
UI.toast(e.message, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function _openChat(userId) {
|
||||
try {
|
||||
const { conversation_id } = await API.chat.start(userId);
|
||||
App.navigate('chat', true, { conversation_id });
|
||||
} catch (e) {
|
||||
UI.toast(e.message, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
function _initial(name) {
|
||||
return (name || '?')[0].toUpperCase();
|
||||
}
|
||||
|
||||
function _esc(s) {
|
||||
if (!s) return '';
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
return {
|
||||
init,
|
||||
_sendRequest, _accept, _decline, _cancel, _removeFriend, _openChat,
|
||||
};
|
||||
|
||||
})();
|
||||
|
|
@ -13,13 +13,14 @@ window.Page_health = (() => {
|
|||
let _activeTab = 'impfung';
|
||||
|
||||
const TABS = [
|
||||
{ key: 'impfung', label: 'Impfpass', icon: '💉' },
|
||||
{ key: 'tierarzt', label: 'Besuche', icon: '🩺' },
|
||||
{ key: 'gewicht', label: 'Gewicht', icon: '⚖️' },
|
||||
{ key: 'medikament', label: 'Medikamente',icon: '💊' },
|
||||
{ key: 'allergie', label: 'Allergien', icon: '🌿' },
|
||||
{ key: 'dokument', label: 'Dokumente', icon: '📄' },
|
||||
{ key: 'praxen', label: 'Praxen', icon: '🏥' },
|
||||
{ key: 'impfung', label: 'Impfpass', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>' },
|
||||
{ key: 'tierarzt', label: 'Besuche', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>' },
|
||||
{ key: 'gewicht', label: 'Gewicht', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#scales"></use></svg>' },
|
||||
{ key: 'medikament', label: 'Medikamente', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>' },
|
||||
{ key: 'allergie', label: 'Allergien', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#leaf"></use></svg>' },
|
||||
{ key: 'dokument', label: 'Dokumente', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg>' },
|
||||
{ key: 'praxen', label: 'Praxen', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>' },
|
||||
{ key: 'symptomcheck', label: 'Symptom-Check', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>' },
|
||||
];
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -56,7 +57,7 @@ window.Page_health = (() => {
|
|||
async function _render() {
|
||||
if (!_appState.activeDog) {
|
||||
_container.innerHTML = UI.emptyState({
|
||||
icon: '💉',
|
||||
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>',
|
||||
title: 'Noch kein Hund angelegt',
|
||||
text: 'Erstelle zuerst ein Hundeprofil.',
|
||||
action: `<button class="btn btn-primary" onclick="App.navigate('dog-profile')">Profil erstellen</button>`,
|
||||
|
|
@ -81,7 +82,7 @@ window.Page_health = (() => {
|
|||
const isActive = dog.id === activeDogId;
|
||||
const av = dog.foto_url
|
||||
? `<img src="${_esc(dog.foto_url)}" alt="${_esc(dog.name)}">`
|
||||
: `<span style="font-size:2.5rem">🐕</span>`;
|
||||
: `<span>${UI.icon('dog')}</span>`;
|
||||
return `
|
||||
<div class="diary-picker-card${isActive ? ' diary-picker-card--active' : ''}"
|
||||
data-dog-id="${dog.id}">
|
||||
|
|
@ -119,7 +120,7 @@ window.Page_health = (() => {
|
|||
_container.innerHTML = `
|
||||
<div class="health-header">
|
||||
<button class="btn btn-secondary btn-sm" id="health-ki-btn">
|
||||
✨ KI-Zusammenfassung
|
||||
${UI.icon('star')} KI-Zusammenfassung
|
||||
</button>
|
||||
</div>
|
||||
<div id="health-reminders"></div>
|
||||
|
|
@ -165,13 +166,17 @@ window.Page_health = (() => {
|
|||
|
||||
if (!items.length) { el.innerHTML = ''; return; }
|
||||
|
||||
const ICONS = { impfung: '💉', entwurmung: '🪱', medikament: '💊' };
|
||||
const ICONS = {
|
||||
impfung: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>',
|
||||
entwurmung: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg>',
|
||||
medikament: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>',
|
||||
};
|
||||
|
||||
el.innerHTML = `
|
||||
<div style="padding:var(--space-3) var(--space-4) 0">
|
||||
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||||
color:var(--c-text-secondary);margin-bottom:var(--space-2)">
|
||||
📅 Anstehende Erinnerungen
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#calendar-dots"></use></svg> Anstehende Erinnerungen
|
||||
</div>
|
||||
${items.map(e => {
|
||||
const ampel = _impfAmpel(e.naechstes);
|
||||
|
|
@ -185,7 +190,7 @@ window.Page_health = (() => {
|
|||
padding:var(--space-2) var(--space-3);margin-bottom:var(--space-1);
|
||||
background:var(--c-surface);border-radius:var(--radius-md);
|
||||
border-left:3px solid ${ampel.color === 'red' ? '#ef4444' : ampel.color === 'yellow' ? '#f59e0b' : '#22c55e'}">
|
||||
<span style="font-size:1.2rem">${ICONS[e._typ] || '📋'}</span>
|
||||
<span style="font-size:1.2rem">${ICONS[e._typ] || '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#clipboard-text"></use></svg>'}</span>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-size:var(--text-sm);font-weight:var(--weight-medium);
|
||||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
|
||||
|
|
@ -199,7 +204,7 @@ window.Page_health = (() => {
|
|||
data-action="reminder-erledigt"
|
||||
data-entry-id="${e.id}" data-entry-typ="${e._typ}"
|
||||
style="flex-shrink:0;white-space:nowrap">
|
||||
✓ Erledigt
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#check"></use></svg> Erledigt
|
||||
</button>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
|
|
@ -302,13 +307,14 @@ window.Page_health = (() => {
|
|||
const entries = _data[_activeTab] || [];
|
||||
|
||||
switch (_activeTab) {
|
||||
case 'impfung': content.innerHTML = _renderImpfungen(entries); break;
|
||||
case 'tierarzt': content.innerHTML = _renderTierarzt(entries); break;
|
||||
case 'gewicht': content.innerHTML = _renderGewicht(entries); break;
|
||||
case 'medikament': content.innerHTML = _renderMedikamente(entries); break;
|
||||
case 'allergie': content.innerHTML = _renderAllergien(entries); break;
|
||||
case 'dokument': content.innerHTML = _renderDokumente(entries); break;
|
||||
case 'praxen': content.innerHTML = _renderPraxen(); break;
|
||||
case 'impfung': content.innerHTML = _renderImpfungen(entries); break;
|
||||
case 'tierarzt': content.innerHTML = _renderTierarzt(entries); break;
|
||||
case 'gewicht': content.innerHTML = _renderGewicht(entries); break;
|
||||
case 'medikament': content.innerHTML = _renderMedikamente(entries); break;
|
||||
case 'allergie': content.innerHTML = _renderAllergien(entries); break;
|
||||
case 'dokument': content.innerHTML = _renderDokumente(entries); break;
|
||||
case 'praxen': content.innerHTML = _renderPraxen(); break;
|
||||
case 'symptomcheck': _renderSymptomCheck(content); break;
|
||||
}
|
||||
|
||||
_bindTabEvents(content);
|
||||
|
|
@ -321,7 +327,7 @@ window.Page_health = (() => {
|
|||
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Impfung eintragen</button>`;
|
||||
|
||||
if (!entries.length) return UI.emptyState({
|
||||
icon: '💉', title: 'Noch keine Impfungen', text: 'Trage alle Impfungen ein, um nichts zu verpassen.', action: addBtn
|
||||
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>', title: 'Noch keine Impfungen', text: 'Trage alle Impfungen ein, um nichts zu verpassen.', action: addBtn
|
||||
});
|
||||
|
||||
const items = entries.map(e => {
|
||||
|
|
@ -337,7 +343,7 @@ window.Page_health = (() => {
|
|||
${UI.time.format(e.datum + 'T00:00:00')}
|
||||
${e.charge_nr ? ` · Ch.-Nr: ${_esc(e.charge_nr)}` : ''}
|
||||
</div>
|
||||
${vetName ? `<div style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-top:var(--space-1)">🏥 ${_esc(vetName)}</div>` : ''}
|
||||
${vetName ? `<div style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-top:var(--space-1)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> ${_esc(vetName)}</div>` : ''}
|
||||
${e.naechstes ? `<div class="health-card-next ampel-text-${ampel.color}">
|
||||
Nächste Impfung: ${UI.time.format(e.naechstes + 'T00:00:00')} ${ampel.icon}
|
||||
</div>` : ''}
|
||||
|
|
@ -365,7 +371,7 @@ window.Page_health = (() => {
|
|||
function _renderTierarzt(entries) {
|
||||
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Besuch eintragen</button>`;
|
||||
if (!entries.length) return UI.emptyState({
|
||||
icon: '🩺', title: 'Noch keine Tierarztbesuche', text: 'Halte alle Tierarztbesuche fest.', action: addBtn
|
||||
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>', title: 'Noch keine Tierarztbesuche', text: 'Halte alle Tierarztbesuche fest.', action: addBtn
|
||||
});
|
||||
|
||||
const items = entries.map(e => {
|
||||
|
|
@ -383,7 +389,7 @@ window.Page_health = (() => {
|
|||
${praxisName ? `
|
||||
<div style="display:flex;align-items:center;gap:var(--space-1);
|
||||
margin-top:var(--space-1);font-size:var(--text-sm);color:var(--c-text-secondary)">
|
||||
🏥 ${_esc(praxisName)}${praxisOrt ? ` · ${_esc(praxisOrt)}` : ''}
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> ${_esc(praxisName)}${praxisOrt ? ` · ${_esc(praxisOrt)}` : ''}
|
||||
</div>` : ''}
|
||||
${e.diagnose ? `<div class="health-card-note"><b>Diagnose:</b> ${_esc(e.diagnose)}</div>` : ''}
|
||||
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
|
||||
|
|
@ -403,7 +409,7 @@ window.Page_health = (() => {
|
|||
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Gewicht eintragen</button>`;
|
||||
|
||||
if (!entries.length) return UI.emptyState({
|
||||
icon: '⚖️', title: 'Noch keine Gewichtseinträge', action: addBtn
|
||||
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#scales"></use></svg>', title: 'Noch keine Gewichtseinträge', action: addBtn
|
||||
});
|
||||
|
||||
const sorted = [...entries].sort((a, b) => a.datum.localeCompare(b.datum));
|
||||
|
|
@ -529,7 +535,7 @@ window.Page_health = (() => {
|
|||
function _renderMedikamente(entries) {
|
||||
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Medikament eintragen</button>`;
|
||||
if (!entries.length) return UI.emptyState({
|
||||
icon: '💊', title: 'Noch keine Medikamente', action: addBtn
|
||||
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>', title: 'Noch keine Medikamente', action: addBtn
|
||||
});
|
||||
|
||||
const aktive = entries.filter(e => e.aktiv);
|
||||
|
|
@ -554,7 +560,7 @@ window.Page_health = (() => {
|
|||
|
||||
return `
|
||||
<div class="health-list">
|
||||
${renderGroup(aktive, '💊 Aktuelle Medikamente')}
|
||||
${renderGroup(aktive, '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> Aktuelle Medikamente')}
|
||||
${renderGroup(inaktive, 'Vergangene Medikamente')}
|
||||
</div>
|
||||
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>
|
||||
|
|
@ -567,7 +573,7 @@ window.Page_health = (() => {
|
|||
function _renderAllergien(entries) {
|
||||
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Allergie eintragen</button>`;
|
||||
if (!entries.length) return UI.emptyState({
|
||||
icon: '🌿', title: 'Noch keine Allergien eingetragen', action: addBtn
|
||||
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#leaf"></use></svg>', title: 'Noch keine Allergien eingetragen', action: addBtn
|
||||
});
|
||||
|
||||
const SCHWEREGRAD = { leicht: '🟡', mittel: '🟠', schwer: '🔴' };
|
||||
|
|
@ -598,7 +604,7 @@ window.Page_health = (() => {
|
|||
function _renderDokumente(entries) {
|
||||
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Dokument hinzufügen</button>`;
|
||||
if (!entries.length) return UI.emptyState({
|
||||
icon: '📄', title: 'Noch keine Dokumente', text: 'Lade Impfpässe, Befunde und mehr hoch.', action: addBtn
|
||||
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg>', title: 'Noch keine Dokumente', text: 'Lade Impfpässe, Befunde und mehr hoch.', action: addBtn
|
||||
});
|
||||
|
||||
const items = entries.map(e => {
|
||||
|
|
@ -610,7 +616,7 @@ window.Page_health = (() => {
|
|||
? `<img src="${e.datei_url}" class="health-doc-thumb" alt="Vorschau"
|
||||
style="width:64px;height:64px;object-fit:cover;border-radius:var(--radius-md);flex-shrink:0">`
|
||||
: `<div style="width:48px;height:48px;display:flex;align-items:center;justify-content:center;
|
||||
font-size:2rem;flex-shrink:0">${isPdf ? '📄' : '📎'}</div>`}
|
||||
font-size:2rem;flex-shrink:0"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg></div>`}
|
||||
<div class="health-card-body">
|
||||
<div class="health-card-title">${_esc(e.bezeichnung)}</div>
|
||||
<div class="health-card-meta">${UI.time.format(e.datum + 'T00:00:00')}</div>
|
||||
|
|
@ -619,7 +625,7 @@ window.Page_health = (() => {
|
|||
? `<a href="${e.datei_url}" target="_blank" rel="noopener"
|
||||
class="btn btn-secondary btn-sm" style="margin-top:var(--space-2);display:inline-flex"
|
||||
onclick="event.stopPropagation()">
|
||||
${isPdf ? '📄 PDF öffnen' : '🖼️ Bild öffnen'}
|
||||
${isPdf ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg> PDF öffnen' : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#image"></use></svg> Bild öffnen'}
|
||||
</a>`
|
||||
: `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">Noch keine Datei hochgeladen</span>`}
|
||||
</div>
|
||||
|
|
@ -668,7 +674,7 @@ window.Page_health = (() => {
|
|||
${fields}
|
||||
${entry.datei_url
|
||||
? (entry.datei_typ === 'pdf'
|
||||
? `<a href="${entry.datei_url}" target="_blank" class="btn btn-secondary btn-sm" style="margin-top:var(--space-3)">📄 PDF öffnen</a>`
|
||||
? `<a href="${entry.datei_url}" target="_blank" class="btn btn-secondary btn-sm" style="margin-top:var(--space-3)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg> PDF öffnen</a>`
|
||||
: `<img src="${entry.datei_url}" style="width:100%;border-radius:var(--radius-md);margin-top:var(--space-3)" alt="Dokument">`)
|
||||
: ''}
|
||||
</div>
|
||||
|
|
@ -717,7 +723,7 @@ window.Page_health = (() => {
|
|||
if (praxis) {
|
||||
const adresse = [praxis.strasse, [praxis.plz, praxis.ort].filter(Boolean).join(' ')].filter(Boolean).join(', ');
|
||||
const tel = praxis.telefon ? ` · <a href="tel:${_esc(praxis.telefon)}">${_esc(praxis.telefon)}</a>` : '';
|
||||
rows.push(['Praxis', `🏥 ${_esc(praxis.name)}${adresse ? `<br><small style="color:var(--c-text-secondary)">${_esc(adresse)}${tel}</small>` : tel}`]);
|
||||
rows.push(['Praxis', `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> ${_esc(praxis.name)}${adresse ? `<br><small style="color:var(--c-text-secondary)">${_esc(adresse)}${tel}</small>` : tel}`]);
|
||||
}
|
||||
} else if (e.tierarzt_name) {
|
||||
rows.push(['Tierarzt', _esc(e.tierarzt_name)]);
|
||||
|
|
@ -940,7 +946,7 @@ window.Page_health = (() => {
|
|||
: `<div class="form-group">
|
||||
<div style="padding:var(--space-3);background:var(--c-bg);border-radius:var(--radius-md);
|
||||
font-size:var(--text-sm);color:var(--c-text-secondary)">
|
||||
🏥 Noch keine Praxis angelegt —
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> Noch keine Praxis angelegt —
|
||||
<button type="button" class="btn btn-ghost btn-sm" style="padding:0;font-size:inherit"
|
||||
data-action="goto-praxen">Praxis im Tab Praxen anlegen</button>
|
||||
</div>
|
||||
|
|
@ -1062,7 +1068,7 @@ window.Page_health = (() => {
|
|||
const inaktive = _praxen.filter(p => !p.aktiv);
|
||||
|
||||
if (!_praxen.length) return UI.emptyState({
|
||||
icon: '🏥', title: 'Noch keine Praxis eingetragen',
|
||||
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>', title: 'Noch keine Praxis eingetragen',
|
||||
text: 'Trage deine Tierarztpraxis ein für schnellen Zugriff.',
|
||||
action: addBtn
|
||||
});
|
||||
|
|
@ -1070,7 +1076,7 @@ window.Page_health = (() => {
|
|||
const renderCard = p => `
|
||||
<div class="health-card praxis-card${!p.aktiv ? ' health-card--inactive' : ''}"
|
||||
data-praxis-id="${p.id}" data-action="open-praxis">
|
||||
<div style="font-size:1.5rem">${p.ist_notfallpraxis ? '🚨' : '🏥'}</div>
|
||||
<div style="font-size:1.5rem">${p.ist_notfallpraxis ? '🚨' : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>'}</div>
|
||||
<div class="health-card-body">
|
||||
<div class="health-card-title">
|
||||
${_esc(p.name)}
|
||||
|
|
@ -1225,6 +1231,111 @@ window.Page_health = (() => {
|
|||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// SYMPTOM-CHECK
|
||||
// ----------------------------------------------------------
|
||||
function _renderSymptomCheck(content) {
|
||||
content.innerHTML = `
|
||||
<div style="padding:var(--space-4)">
|
||||
<div class="card" style="padding:var(--space-4)">
|
||||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-4)">
|
||||
Beschreibe die Symptome deines Hundes. Die KI gibt eine erste Einschätzung — kein Ersatz für den Tierarzt.
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="symptom-input">Symptome</label>
|
||||
<textarea id="symptom-input" class="form-control" rows="4"
|
||||
placeholder="z.B. frisst nicht, trinkt viel, schläft mehr als sonst..."></textarea>
|
||||
</div>
|
||||
<button id="symptom-submit-btn" class="btn btn-primary" style="width:100%">
|
||||
Symptome analysieren <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>
|
||||
</button>
|
||||
<div id="symptom-result" style="display:none;margin-top:var(--space-5)"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
content.querySelector('#symptom-submit-btn').addEventListener('click', async function () {
|
||||
const btn = this;
|
||||
const textarea = content.querySelector('#symptom-input');
|
||||
const resultEl = content.querySelector('#symptom-result');
|
||||
const symptoms = textarea.value.trim();
|
||||
|
||||
if (!symptoms) {
|
||||
UI.toast.warning('Bitte Symptome eingeben.');
|
||||
return;
|
||||
}
|
||||
|
||||
await UI.asyncButton(btn, async () => {
|
||||
resultEl.style.display = 'none';
|
||||
resultEl.innerHTML = '';
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await API.post(
|
||||
`/dogs/${_appState.activeDog.id}/health/symptom-check`,
|
||||
{ symptoms }
|
||||
);
|
||||
} catch (err) {
|
||||
if (err.status === 402) {
|
||||
resultEl.innerHTML = `
|
||||
<div class="card" style="padding:var(--space-4);border:1px solid var(--c-warning,#f59e0b)">
|
||||
<p style="margin:0;font-size:var(--text-sm)">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#star"></use></svg> Dieses Feature benötigt Ban Yaro Plus oder einen laufenden KI-Server.
|
||||
</p>
|
||||
</div>`;
|
||||
} else if (err.status === 503) {
|
||||
resultEl.innerHTML = `
|
||||
<div class="card" style="padding:var(--space-4);border:1px solid var(--c-danger,#ef4444)">
|
||||
<p style="margin:0;font-size:var(--text-sm)">
|
||||
KI-Server nicht erreichbar. Bitte später versuchen.
|
||||
</p>
|
||||
</div>`;
|
||||
} else {
|
||||
UI.toast.error(err.message || 'Fehler bei der Symptomanalyse.');
|
||||
return;
|
||||
}
|
||||
resultEl.style.display = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const DRINGLICHKEIT = {
|
||||
beobachten: { badgeClass: 'badge-success', label: '🟢 Beobachten' },
|
||||
tierarzt: { badgeClass: 'badge-warning', label: '🟡 Zum Tierarzt' },
|
||||
notfall: { badgeClass: 'badge-danger', label: '🔴 Notfall — sofort zum Tierarzt!' },
|
||||
};
|
||||
const d = DRINGLICHKEIT[result.dringlichkeit] || { badgeClass: 'badge-primary', label: _esc(result.dringlichkeit) };
|
||||
|
||||
const hinweiseHtml = (result.hinweise || []).length
|
||||
? `<ul style="margin:var(--space-2) 0 0;padding-left:var(--space-5);font-size:var(--text-sm)">
|
||||
${result.hinweise.map(h => `<li style="margin-bottom:var(--space-1)">${_esc(h)}</li>`).join('')}
|
||||
</ul>`
|
||||
: '';
|
||||
|
||||
const zumTierarztHtml = result.zum_tierarzt_wenn
|
||||
? `<div style="margin-top:var(--space-3);padding:var(--space-3);
|
||||
background:var(--c-surface-alt,var(--c-surface));
|
||||
border-radius:var(--radius-md);font-size:var(--text-sm)">
|
||||
<strong>Zum Tierarzt wenn:</strong> ${_esc(result.zum_tierarzt_wenn)}
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
resultEl.innerHTML = `
|
||||
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3)">
|
||||
<span class="badge ${d.badgeClass}" style="font-size:var(--text-sm);padding:var(--space-1) var(--space-3)">
|
||||
${d.label}
|
||||
</span>
|
||||
</div>
|
||||
${result.einschaetzung
|
||||
? `<p style="font-size:var(--text-sm);line-height:1.6;margin:0">${_esc(result.einschaetzung)}</p>`
|
||||
: ''}
|
||||
${hinweiseHtml}
|
||||
${zumTierarztHtml}
|
||||
`;
|
||||
resultEl.style.display = '';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// KI-ZUSAMMENFASSUNG
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -1235,7 +1346,7 @@ window.Page_health = (() => {
|
|||
try {
|
||||
const { zusammenfassung } = await API.health.kiZusammenfassung(_appState.activeDog.id);
|
||||
UI.modal.open({
|
||||
title: '✨ KI-Gesundheitsbericht',
|
||||
title: `${UI.icon('star')} KI-Gesundheitsbericht`,
|
||||
body: `<div style="white-space:pre-wrap;line-height:1.7;font-size:var(--text-sm)">${_esc(zusammenfassung)}</div>`,
|
||||
});
|
||||
} catch (err) {
|
||||
|
|
|
|||
413
backend/static/js/pages/knigge.js
Normal file
413
backend/static/js/pages/knigge.js
Normal file
|
|
@ -0,0 +1,413 @@
|
|||
/* ============================================================
|
||||
BAN YARO — Hunde-Knigge
|
||||
Seiten-Modul: Begegnungen, Community-Voting, KI-Rat, Haftpflicht.
|
||||
============================================================ */
|
||||
|
||||
window.Page_knigge = (() => {
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// MODUL-STATE
|
||||
// ----------------------------------------------------------
|
||||
let _container = null;
|
||||
let _appState = null;
|
||||
|
||||
// Voting-State: { szenario_id: { counts: {}, user_answer: null } }
|
||||
const _voteState = {};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// HARDCODED INHALTE
|
||||
// ----------------------------------------------------------
|
||||
const BEGEGNUNGEN = [
|
||||
{
|
||||
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>',
|
||||
titel: 'Fremder Hund',
|
||||
tipps: 'Kurze Leine, ruhig bleiben, Hunde schnüffeln lassen wenn beide entspannt. Eskalation: weglenken, Richtung wechseln.',
|
||||
},
|
||||
{
|
||||
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#users"></use></svg>',
|
||||
titel: 'Kinder',
|
||||
tipps: 'Hund nie unbeaufsichtigt mit Kindern. Kind fragen ob es streicheln darf. Hund dahinter positionieren, nicht zwischen Kind und Weg.',
|
||||
},
|
||||
{
|
||||
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#person-simple-run"></use></svg>',
|
||||
titel: 'Radfahrer',
|
||||
tipps: 'Hund an die Seite nehmen. Fahrrad = potentielle Bedrohung für manche Hunde. Frühzeitig weglenken.',
|
||||
},
|
||||
{
|
||||
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#person-simple-run"></use></svg>',
|
||||
titel: 'Jogger',
|
||||
tipps: 'Kurze Leine, Abstand halten, Hund nicht anspringen lassen.',
|
||||
},
|
||||
{
|
||||
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#car"></use></svg>',
|
||||
titel: 'ÖPNV',
|
||||
tipps: 'Maulkorbpflicht gilt im ÖPNV (Deutschland-weit). Kleine Hunde in Transportbox kostenlos, große Hunde brauchen Fahrschein. Regeln je Stadt unterschiedlich.',
|
||||
},
|
||||
{
|
||||
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#shopping-cart"></use></svg>',
|
||||
titel: 'Supermarkt / Geschäfte',
|
||||
tipps: 'Grundsätzlich Hausrecht des Betreibers. "Hunde willkommen"-Schild = explizite Einladung. Im Zweifel fragen. Außen anbinden nur kurz und beaufsichtigt.',
|
||||
},
|
||||
];
|
||||
|
||||
const SZENARIEN = [
|
||||
{
|
||||
id: 'begegnung_leine',
|
||||
frage: 'Dein Hund ist gut sozialisiert und läuft frei. Ein angeleinten Hund kommt entgegen. Was tust du?',
|
||||
antworten: [
|
||||
{ key: 'a', text: 'Hund weiterlaufen lassen — er ist ja friedlich' },
|
||||
{ key: 'b', text: 'Hund anleinen und Abstand halten' },
|
||||
{ key: 'c', text: 'Besitzer fragen ob ein Treffen ok ist' },
|
||||
],
|
||||
richtig: 'b',
|
||||
erklaerung: 'Freilaufende Hunde auf angeleine Hunde zulaufen zu lassen ist unhöflich und kann den angeleinten Hund in Stress versetzen ("Leinenfrust"). Immer erst anleinen und Abstand halten.',
|
||||
},
|
||||
{
|
||||
id: 'gassi_kot',
|
||||
frage: 'Dein Hund macht sein Geschäft im Park abseits des Weges im Gebüsch. Was machst du?',
|
||||
antworten: [
|
||||
{ key: 'a', text: 'Liegenlassen — im Gebüsch stört es niemanden' },
|
||||
{ key: 'b', text: 'Aufsammeln, auch wenn es versteckt liegt' },
|
||||
{ key: 'c', text: 'Nur aufsammeln wenn jemand zuschaut' },
|
||||
],
|
||||
richtig: 'b',
|
||||
erklaerung: 'Kot grundsätzlich immer aufsammeln — auch im Gebüsch. Kinder spielen überall, und Parasiten (z.B. Spulwurm) können für Menschen gefährlich sein.',
|
||||
},
|
||||
{
|
||||
id: 'restaurant_hund',
|
||||
frage: 'Im Restaurant-Außenbereich sitzt du mit deinem Hund. Ein anderer Gast bittet dich deinen Hund wegzunehmen weil er Angst hat. Was tust du?',
|
||||
antworten: [
|
||||
{ key: 'a', text: 'Ablehnen — Außenbereich ist hundefreundlich' },
|
||||
{ key: 'b', text: 'Hund wegsetzen oder selbst weiter hinten platzieren' },
|
||||
{ key: 'c', text: 'Personal entscheiden lassen' },
|
||||
],
|
||||
richtig: 'c',
|
||||
erklaerung: 'Das Personal / der Betreiber entscheidet über das Hausrecht. Gut wäre es, selbst Kompromissbereitschaft zu zeigen und den Hund etwas wegzurücken — das deeskaliert und signalisiert Rücksicht.',
|
||||
},
|
||||
{
|
||||
id: 'anleine_pflicht',
|
||||
frage: 'Im Park gibt es keine Schilder. Muss dein Hund an die Leine?',
|
||||
antworten: [
|
||||
{ key: 'a', text: 'Nein — kein Schild bedeutet keine Pflicht' },
|
||||
{ key: 'b', text: 'Kommt auf die Gemeindeordnung an' },
|
||||
{ key: 'c', text: 'Ja — immer Leinenpflicht in öffentlichen Parks' },
|
||||
],
|
||||
richtig: 'b',
|
||||
erklaerung: 'Leinenpflicht ist Ländersache und variiert stark. Viele Bundesländer haben eine allgemeine Anleinpflicht in Ortschaften oder Parks. Im Zweifel Hund anleinen oder Gemeindewebsite prüfen.',
|
||||
},
|
||||
];
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// INIT
|
||||
// ----------------------------------------------------------
|
||||
async function init(container, appState) {
|
||||
_container = container;
|
||||
_appState = appState;
|
||||
_render();
|
||||
_loadAllVotes();
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
// statische Seite — kein Reload nötig
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// HAUPT-RENDER
|
||||
// ----------------------------------------------------------
|
||||
function _render() {
|
||||
_container.innerHTML = `
|
||||
<div id="knigge-wrap">
|
||||
${_renderBegegnungen()}
|
||||
${_renderVoting()}
|
||||
${_renderKiRat()}
|
||||
${_renderHaftpflicht()}
|
||||
</div>
|
||||
`;
|
||||
_bindAccordion();
|
||||
_bindVoting();
|
||||
_bindKiRat();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// SECTION 1: BEGEGNUNGEN — Accordion-Karten
|
||||
// ----------------------------------------------------------
|
||||
function _renderBegegnungen() {
|
||||
const cards = BEGEGNUNGEN.map((b, i) => `
|
||||
<div class="knigge-accordion" id="acc-${i}">
|
||||
<button class="knigge-accordion-head" data-acc="${i}" aria-expanded="false">
|
||||
<span>${b.icon} <strong>${_esc(b.titel)}</strong></span>
|
||||
<span class="knigge-accordion-arrow">${UI.icon('caret-down')}</span>
|
||||
</button>
|
||||
<div class="knigge-accordion-body" id="acc-body-${i}" hidden>
|
||||
<p style="color:var(--c-text);line-height:1.6">${_esc(b.tipps)}</p>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
return `
|
||||
<h2 style="font-size:var(--text-lg);font-weight:700;margin:var(--space-6) 0 var(--space-3)">
|
||||
${UI.icon('paw-print')} Begegnungen
|
||||
</h2>
|
||||
<div class="card" style="padding:0;overflow:hidden">
|
||||
${cards}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function _bindAccordion() {
|
||||
_container.querySelectorAll('.knigge-accordion-head').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const i = btn.dataset.acc;
|
||||
const body = document.getElementById(`acc-body-${i}`);
|
||||
const arrow = btn.querySelector('.knigge-accordion-arrow');
|
||||
const open = !body.hidden;
|
||||
body.hidden = open;
|
||||
btn.setAttribute('aria-expanded', String(!open));
|
||||
arrow.innerHTML = open ? UI.icon('caret-down') : UI.icon('caret-up');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// SECTION 2: COMMUNITY VOTING
|
||||
// ----------------------------------------------------------
|
||||
function _renderVoting() {
|
||||
const cards = SZENARIEN.map(s => `
|
||||
<div class="card" style="margin-bottom:var(--space-4)" id="sz-${s.id}">
|
||||
<p style="font-weight:var(--weight-semibold);margin-bottom:var(--space-3);line-height:1.5">
|
||||
${_esc(s.frage)}
|
||||
</p>
|
||||
<div class="knigge-vote-options" id="opts-${s.id}">
|
||||
${s.antworten.map(a => `
|
||||
<button class="knigge-vote-btn btn btn-secondary"
|
||||
data-sz="${s.id}" data-key="${a.key}"
|
||||
style="width:100%;margin-bottom:var(--space-2);justify-content:flex-start;text-align:left">
|
||||
${_esc(a.text)}
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
<div class="knigge-vote-result hidden" id="res-${s.id}"></div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
return `
|
||||
<h2 style="font-size:var(--text-lg);font-weight:700;margin:var(--space-6) 0 var(--space-3)">
|
||||
${UI.icon('star')} Was wäre richtig?
|
||||
</h2>
|
||||
${cards}
|
||||
`;
|
||||
}
|
||||
|
||||
function _bindVoting() {
|
||||
_container.querySelectorAll('.knigge-vote-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const szId = btn.dataset.sz;
|
||||
const key = btn.dataset.key;
|
||||
if (!_appState.user) {
|
||||
UI.toast.warning('Bitte melde dich an um abzustimmen.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await API.knigge.vote(szId, key);
|
||||
_voteState[szId] = { counts: result.counts, user_answer: result.user_answer };
|
||||
_renderVoteResult(szId);
|
||||
} catch (err) {
|
||||
UI.toast.error(err.message || 'Fehler beim Abstimmen.');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function _loadAllVotes() {
|
||||
for (const s of SZENARIEN) {
|
||||
try {
|
||||
const result = await API.knigge.votes(s.id);
|
||||
_voteState[s.id] = { counts: result.counts, user_answer: result.user_answer };
|
||||
if (result.user_answer) {
|
||||
_renderVoteResult(s.id);
|
||||
}
|
||||
} catch {
|
||||
// ignorieren — Votes werden on-demand geladen
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _renderVoteResult(szId) {
|
||||
const szenario = SZENARIEN.find(s => s.id === szId);
|
||||
if (!szenario) return;
|
||||
const state = _voteState[szId];
|
||||
if (!state) return;
|
||||
|
||||
const optsEl = document.getElementById(`opts-${szId}`);
|
||||
const resEl = document.getElementById(`res-${szId}`);
|
||||
if (!optsEl || !resEl) return;
|
||||
|
||||
// Optionen ausblenden
|
||||
optsEl.classList.add('hidden');
|
||||
resEl.classList.remove('hidden');
|
||||
|
||||
const counts = state.counts || {};
|
||||
const userAnswer = state.user_answer;
|
||||
const total = Object.values(counts).reduce((s, c) => s + c, 0) || 1;
|
||||
const isCorrect = userAnswer === szenario.richtig;
|
||||
|
||||
const bars = szenario.antworten.map(a => {
|
||||
const cnt = counts[a.key] || 0;
|
||||
const pct = Math.round((cnt / total) * 100);
|
||||
const isU = a.key === userAnswer;
|
||||
const isR = a.key === szenario.richtig;
|
||||
const color = isR
|
||||
? 'var(--c-success, #22c55e)'
|
||||
: (isU && !isR ? 'var(--c-danger, #ef4444)' : 'var(--c-border)');
|
||||
return `
|
||||
<div style="margin-bottom:var(--space-3)">
|
||||
<div style="display:flex;justify-content:space-between;margin-bottom:4px;font-size:var(--text-sm)">
|
||||
<span style="color:${isU ? 'var(--c-text)' : 'var(--c-text-secondary)'};font-weight:${isU ? 'var(--weight-semibold)' : 'normal'}">
|
||||
${isU ? UI.icon('arrow-right') + ' ' : ''}${_esc(a.text)}${isR ? ' ' + UI.icon('check') : ''}
|
||||
</span>
|
||||
<span style="color:var(--c-text-secondary)">${pct}% (${cnt})</span>
|
||||
</div>
|
||||
<div style="background:var(--c-surface-2);border-radius:4px;height:8px;overflow:hidden">
|
||||
<div style="width:${pct}%;background:${color};height:8px;border-radius:4px;transition:width 0.4s"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const badge = isCorrect
|
||||
? `<span style="color:var(--c-success,#22c55e);font-weight:var(--weight-semibold)">${UI.icon('check')} Richtig!</span>`
|
||||
: `<span style="color:var(--c-danger,#ef4444);font-weight:var(--weight-semibold)">${UI.icon('x')} Nicht ganz — </span>`;
|
||||
|
||||
resEl.innerHTML = `
|
||||
<div style="margin-bottom:var(--space-4)">${bars}</div>
|
||||
<div style="background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-3);font-size:var(--text-sm);line-height:1.5">
|
||||
${badge}
|
||||
<span style="color:var(--c-text-secondary)">${_esc(szenario.erklaerung)}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// SECTION 3: KI-SITUATIONSBERATER
|
||||
// ----------------------------------------------------------
|
||||
function _renderKiRat() {
|
||||
return `
|
||||
<h2 style="font-size:var(--text-lg);font-weight:700;margin:var(--space-6) 0 var(--space-3)">
|
||||
${UI.icon('robot')} KI-Situationsberater
|
||||
</h2>
|
||||
<div class="card">
|
||||
<textarea id="ki-situation-input" class="form-control"
|
||||
rows="3"
|
||||
placeholder="Beschreibe deine Situation…"
|
||||
style="margin-bottom:var(--space-3)"></textarea>
|
||||
<button class="btn btn-primary" id="ki-rat-btn" style="width:100%">
|
||||
Rat holen ${UI.icon('robot')}
|
||||
</button>
|
||||
<div id="ki-rat-result" style="margin-top:var(--space-4);display:none"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function _bindKiRat() {
|
||||
const btn = _container.querySelector('#ki-rat-btn');
|
||||
const input = _container.querySelector('#ki-situation-input');
|
||||
const result = _container.querySelector('#ki-rat-result');
|
||||
|
||||
btn?.addEventListener('click', async () => {
|
||||
const situation = input?.value?.trim();
|
||||
if (!situation) {
|
||||
UI.toast.warning('Bitte beschreibe zuerst deine Situation.');
|
||||
return;
|
||||
}
|
||||
if (!_appState.user) {
|
||||
UI.toast.warning('Bitte melde dich an um den KI-Rat zu nutzen.');
|
||||
return;
|
||||
}
|
||||
|
||||
UI.setLoading(btn, true);
|
||||
result.style.display = 'none';
|
||||
|
||||
try {
|
||||
const data = await API.knigge.kiRat(situation);
|
||||
result.innerHTML = `
|
||||
<div style="background:var(--c-surface-2);border-radius:var(--radius-md);
|
||||
padding:var(--space-4);line-height:1.6;color:var(--c-text)">
|
||||
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||||
color:var(--c-text-secondary);margin-bottom:var(--space-2)">${UI.icon('robot')} KI-Rat</div>
|
||||
${_esc(data.rat)}
|
||||
</div>
|
||||
`;
|
||||
result.style.display = 'block';
|
||||
} catch (err) {
|
||||
const is402 = err.status === 402 || err.status === 503;
|
||||
result.innerHTML = `
|
||||
<div style="background:var(--c-surface-2);border-radius:var(--radius-md);
|
||||
padding:var(--space-4);color:var(--c-text-secondary);font-size:var(--text-sm)">
|
||||
${is402
|
||||
? 'Für KI-Rat wird Ban Yaro Plus oder ein laufender KI-Server benötigt.'
|
||||
: _esc(err.message || 'Fehler beim KI-Abruf.')}
|
||||
</div>
|
||||
`;
|
||||
result.style.display = 'block';
|
||||
} finally {
|
||||
UI.setLoading(btn, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// SECTION 4: HAFTPFLICHT-HINWEISE
|
||||
// ----------------------------------------------------------
|
||||
function _renderHaftpflicht() {
|
||||
return `
|
||||
<h2 style="font-size:var(--text-lg);font-weight:700;margin:var(--space-6) 0 var(--space-3)">
|
||||
${UI.icon('shield')} Haftpflicht-Hinweise
|
||||
</h2>
|
||||
<div class="card" style="margin-bottom:var(--space-8)">
|
||||
<ul style="list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:var(--space-3)">
|
||||
<li style="display:flex;gap:var(--space-3);align-items:flex-start">
|
||||
<span style="font-size:1.2rem;flex-shrink:0">${UI.icon('scales')}</span>
|
||||
<span style="color:var(--c-text);line-height:1.5">
|
||||
Hundehalter haften unbegrenzt für Schäden die ihr Hund verursacht
|
||||
(§ 833 BGB) — auch ohne Verschulden.
|
||||
</span>
|
||||
</li>
|
||||
<li style="display:flex;gap:var(--space-3);align-items:flex-start">
|
||||
<span style="font-size:1.2rem;flex-shrink:0">${UI.icon('map-trifold')}</span>
|
||||
<span style="color:var(--c-text);line-height:1.5">
|
||||
Eine Hundehaftpflichtversicherung ist in einigen Bundesländern
|
||||
(Bayern, Hamburg, Berlin u.a.) Pflicht.
|
||||
</span>
|
||||
</li>
|
||||
<li style="display:flex;gap:var(--space-3);align-items:flex-start">
|
||||
<span style="font-size:1.2rem;flex-shrink:0">${UI.icon('info')}</span>
|
||||
<span style="color:var(--c-text);line-height:1.5">
|
||||
Empfehlung: Absicherung ab ~50 €/Jahr.
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p style="margin-top:var(--space-4);font-size:var(--text-xs);color:var(--c-text-muted)">
|
||||
Dies ist keine Rechtsberatung.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// HELPER
|
||||
// ----------------------------------------------------------
|
||||
function _esc(str) {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// PUBLIC
|
||||
// ----------------------------------------------------------
|
||||
return { init, refresh };
|
||||
|
||||
})();
|
||||
688
backend/static/js/pages/lost.js
Normal file
688
backend/static/js/pages/lost.js
Normal file
|
|
@ -0,0 +1,688 @@
|
|||
/* ============================================================
|
||||
BAN YARO — Verlorener Hund (Sprint 11)
|
||||
Seiten-Modul: Leaflet-Karte + Meldungsliste + Melden-Formular.
|
||||
============================================================ */
|
||||
|
||||
window.Page_lost = (() => {
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// MODUL-STATE
|
||||
// ----------------------------------------------------------
|
||||
let _container = null;
|
||||
let _appState = null;
|
||||
let _map = null;
|
||||
let _markers = [];
|
||||
let _userMarker = null;
|
||||
let _reports = [];
|
||||
let _userPos = null;
|
||||
let _leafletLoaded = false;
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// INIT
|
||||
// ----------------------------------------------------------
|
||||
async function init(container, appState) {
|
||||
_container = container;
|
||||
_appState = appState;
|
||||
await _render();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// REFRESH — Navigation zur bereits geladenen Seite
|
||||
// ----------------------------------------------------------
|
||||
async function refresh() {
|
||||
if (_userPos) await _loadReports();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// OPEN NEW — vom + Button
|
||||
// ----------------------------------------------------------
|
||||
function openNew() {
|
||||
_showReportForm();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// RENDER — Grundstruktur aufbauen
|
||||
// ----------------------------------------------------------
|
||||
async function _render() {
|
||||
_container.innerHTML = `
|
||||
<div style="display:flex;gap:var(--space-2);margin-bottom:var(--space-3);flex-wrap:wrap">
|
||||
<button class="btn btn-secondary" id="lost-btn-locate">${UI.icon('map-pin')} Mein Standort</button>
|
||||
<button class="btn btn-primary" id="lost-btn-report">${UI.icon('magnifying-glass')} Hund vermisst melden</button>
|
||||
</div>
|
||||
|
||||
<div id="lost-map"
|
||||
style="height:280px;border-radius:var(--radius-md);overflow:hidden;
|
||||
background:var(--c-surface-2)">
|
||||
</div>
|
||||
<div style="font-size:10px;color:var(--c-text-secondary);
|
||||
text-align:right;margin-bottom:var(--space-4);
|
||||
padding:2px var(--space-2) 0">
|
||||
© OpenStreetMap-Mitwirkende
|
||||
</div>
|
||||
|
||||
<p id="lost-info"
|
||||
style="font-size:var(--text-sm);color:var(--c-text-secondary);
|
||||
margin-bottom:var(--space-3)">
|
||||
Standort wird ermittelt…
|
||||
</p>
|
||||
|
||||
<div id="lost-held"></div>
|
||||
<div id="lost-list"></div>
|
||||
`;
|
||||
|
||||
document.getElementById('lost-btn-locate')
|
||||
?.addEventListener('click', _locateUser);
|
||||
document.getElementById('lost-btn-report')
|
||||
?.addEventListener('click', _showReportForm);
|
||||
|
||||
await _loadLeaflet();
|
||||
_initMap();
|
||||
setTimeout(() => _map?.invalidateSize(), 100);
|
||||
await _locateAndLoad();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// LEAFLET DYNAMISCH LADEN
|
||||
// ----------------------------------------------------------
|
||||
async function _loadLeaflet() {
|
||||
if (_leafletLoaded || window.L) { _leafletLoaded = true; return; }
|
||||
|
||||
await new Promise(resolve => {
|
||||
if (document.querySelector('link[href*="leaflet"]')) { resolve(); return; }
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = '/css/leaflet.css';
|
||||
link.onload = resolve;
|
||||
link.onerror = resolve;
|
||||
document.head.appendChild(link);
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
if (document.querySelector('script[src*="leaflet"]')) { resolve(); return; }
|
||||
const s = document.createElement('script');
|
||||
s.src = '/js/leaflet.js';
|
||||
s.onload = resolve;
|
||||
s.onerror = reject;
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
|
||||
_leafletLoaded = true;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// KARTE INITIALISIEREN
|
||||
// ----------------------------------------------------------
|
||||
function _initMap() {
|
||||
const mapEl = document.getElementById('lost-map');
|
||||
if (!mapEl || !window.L || _map) return;
|
||||
|
||||
_map = L.map('lost-map', { zoomControl: true, attributionControl: false })
|
||||
.setView([51.1657, 10.4515], 6);
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
}).addTo(_map);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// STANDORT ERMITTELN + LADEN
|
||||
// ----------------------------------------------------------
|
||||
async function _locateAndLoad() {
|
||||
try {
|
||||
_userPos = await API.getLocation({ timeout: 8000 });
|
||||
_showUserOnMap();
|
||||
} catch {
|
||||
_userPos = null;
|
||||
}
|
||||
await _loadReports();
|
||||
}
|
||||
|
||||
async function _locateUser() {
|
||||
const btn = document.getElementById('lost-btn-locate');
|
||||
UI.setLoading(btn, true);
|
||||
try {
|
||||
_userPos = await API.getLocation({ timeout: 8000 });
|
||||
_showUserOnMap();
|
||||
if (_map) _map.setView([_userPos.lat, _userPos.lon], 13);
|
||||
await _loadReports();
|
||||
} catch {
|
||||
UI.toast.warning('Standort konnte nicht ermittelt werden.');
|
||||
}
|
||||
UI.setLoading(btn, false);
|
||||
}
|
||||
|
||||
function _showUserOnMap() {
|
||||
if (!_map || !window.L || !_userPos) return;
|
||||
if (_userMarker) _map.removeLayer(_userMarker);
|
||||
_userMarker = L.circleMarker([_userPos.lat, _userPos.lon], {
|
||||
radius : 9,
|
||||
fillColor : '#3498db',
|
||||
color : '#fff',
|
||||
weight : 2,
|
||||
fillOpacity : 0.9,
|
||||
}).addTo(_map).bindPopup('<b>Du bist hier</b>');
|
||||
_map.setView([_userPos.lat, _userPos.lon], 13);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// MELDUNGEN LADEN
|
||||
// ----------------------------------------------------------
|
||||
async function _loadReports() {
|
||||
const infoEl = document.getElementById('lost-info');
|
||||
|
||||
if (!_userPos) {
|
||||
_reports = [];
|
||||
_renderHeld();
|
||||
_renderList();
|
||||
if (infoEl) infoEl.textContent =
|
||||
'Standort unbekannt — bitte Standort freigeben (📍 Mein Standort).';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
_reports = await API.lost.list(_userPos.lat, _userPos.lon, 25);
|
||||
_renderMarkers();
|
||||
_renderHeld();
|
||||
_renderList();
|
||||
_updateBadge(_reports.length);
|
||||
if (infoEl) {
|
||||
infoEl.textContent = _reports.length > 0
|
||||
? `${_reports.length} vermisste${_reports.length !== 1 ? 'r Hund' : 'r Hund'} im Umkreis von 25 km`
|
||||
: 'Keine vermissten Hunde in deiner Nähe (25 km Radius). 🐾';
|
||||
}
|
||||
} catch {
|
||||
UI.toast.error('Meldungen konnten nicht geladen werden.');
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// KARTEN-MARKER
|
||||
// ----------------------------------------------------------
|
||||
function _renderMarkers() {
|
||||
if (!_map || !window.L) return;
|
||||
_markers.forEach(m => _map.removeLayer(m));
|
||||
_markers = [];
|
||||
|
||||
_reports.forEach(r => {
|
||||
const icon = L.divIcon({
|
||||
className : '',
|
||||
html : `<div style="
|
||||
background:#e74c3c;color:#fff;border-radius:50%;
|
||||
width:34px;height:34px;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
font-size:17px;box-shadow:0 2px 6px rgba(0,0,0,.35);
|
||||
border:2px solid #fff">🐕</div>`,
|
||||
iconSize : [34, 34],
|
||||
iconAnchor : [17, 17],
|
||||
});
|
||||
|
||||
const distStr = r.distanz_m !== undefined
|
||||
? (r.distanz_m < 1000 ? `${r.distanz_m} m` : `${(r.distanz_m / 1000).toFixed(1)} km`)
|
||||
: '';
|
||||
|
||||
const marker = L.marker([r.lat, r.lon], { icon })
|
||||
.addTo(_map)
|
||||
.bindPopup(`
|
||||
<b>🔍 ${_escape(r.name)}</b><br>
|
||||
${r.rasse ? _escape(r.rasse) + '<br>' : ''}
|
||||
${distStr ? `<small>📍 ${distStr} entfernt</small><br>` : ''}
|
||||
<small>📅 ${_fmtDate(r.created_at)}</small>
|
||||
`);
|
||||
|
||||
marker.on('click', () => _openDetail(r));
|
||||
_markers.push(marker);
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// HELD DES TAGES
|
||||
// ----------------------------------------------------------
|
||||
function _renderHeld() {
|
||||
const heldEl = document.getElementById('lost-held');
|
||||
if (!heldEl) return;
|
||||
|
||||
// Letzter gefundener Hund (is_active=0, gefunden_at gesetzt) — wir laden
|
||||
// sie nicht separat, daher nutzen wir die aktiven; für "Held" einen eigenen
|
||||
// API-Call wäre übertrieben. Stattdessen zeigen wir es nur wenn die Liste
|
||||
// kommt und wir einen kürzlich-gefundenen kennen. Wir überspringen hier
|
||||
// den separaten Endpunkt und blenden die Sektion aus wenn leer.
|
||||
heldEl.innerHTML = '';
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// LISTE
|
||||
// ----------------------------------------------------------
|
||||
function _renderList() {
|
||||
const listEl = document.getElementById('lost-list');
|
||||
if (!listEl) return;
|
||||
|
||||
if (_reports.length === 0) {
|
||||
listEl.innerHTML = UI.emptyState({
|
||||
icon : '🐾',
|
||||
title : 'Keine vermissten Hunde',
|
||||
text : 'In deiner Nähe (25 km) werden aktuell keine Hunde vermisst.',
|
||||
action: `<button class="btn btn-primary" id="lost-empty-report">🔍 Hund melden</button>`,
|
||||
});
|
||||
listEl.querySelector('#lost-empty-report')
|
||||
?.addEventListener('click', _showReportForm);
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = _reports.map(r => _reportCard(r)).join('');
|
||||
listEl.querySelectorAll('[data-lost-id]').forEach(card => {
|
||||
card.addEventListener('click', () => {
|
||||
const r = _reports.find(x => x.id === parseInt(card.dataset.lostId));
|
||||
if (r) _openDetail(r);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _reportCard(r) {
|
||||
const isOwn = _appState.user && _appState.user.id === r.user_id;
|
||||
const distStr = r.distanz_m !== undefined
|
||||
? (r.distanz_m < 1000 ? `${r.distanz_m} m` : `${(r.distanz_m / 1000).toFixed(1)} km`)
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="card" data-lost-id="${r.id}"
|
||||
style="cursor:pointer;margin-bottom:var(--space-3);
|
||||
border-left:4px solid #e74c3c">
|
||||
<div style="display:flex;gap:var(--space-3);align-items:flex-start">
|
||||
${r.foto_url
|
||||
? `<img src="${r.foto_url}" alt="Foto"
|
||||
loading="lazy"
|
||||
style="width:72px;height:72px;object-fit:cover;
|
||||
border-radius:var(--radius-md);flex-shrink:0">`
|
||||
: `<div style="width:72px;height:72px;background:var(--c-surface-2);
|
||||
border-radius:var(--radius-md);flex-shrink:0;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
font-size:2rem">🐕</div>`}
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="display:flex;align-items:center;gap:var(--space-2);
|
||||
margin-bottom:var(--space-1);flex-wrap:wrap">
|
||||
<span style="font-weight:var(--weight-semibold);font-size:var(--text-base)">
|
||||
${_escape(r.name)}
|
||||
</span>
|
||||
${r.rasse
|
||||
? `<span class="badge">${_escape(r.rasse)}</span>`
|
||||
: ''}
|
||||
${isOwn
|
||||
? '<span class="badge badge-warning">Meine Meldung</span>'
|
||||
: ''}
|
||||
${distStr
|
||||
? `<span style="margin-left:auto;color:var(--c-text-secondary);
|
||||
font-size:var(--text-sm);white-space:nowrap">
|
||||
📍 ${distStr}
|
||||
</span>`
|
||||
: ''}
|
||||
</div>
|
||||
<p style="margin:0 0 var(--space-1);font-size:var(--text-sm);
|
||||
color:var(--c-text)">
|
||||
${_escape(r.beschreibung.slice(0, 120))}${r.beschreibung.length > 120 ? '…' : ''}
|
||||
</p>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
|
||||
Gemeldet ${_fmtDate(r.created_at)}
|
||||
${r.melder_name ? '· ' + _escape(r.melder_name.split(' ')[0]) : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// DETAIL-MODAL
|
||||
// ----------------------------------------------------------
|
||||
function _openDetail(r) {
|
||||
const isOwn = _appState.user && _appState.user.id === r.user_id;
|
||||
const isAdmin = _appState.user?.rolle === 'admin';
|
||||
const distStr = r.distanz_m !== undefined
|
||||
? (r.distanz_m < 1000 ? `${r.distanz_m} m` : `${(r.distanz_m / 1000).toFixed(1)} km`)
|
||||
: '';
|
||||
|
||||
const body = `
|
||||
${r.foto_url
|
||||
? `<img src="${r.foto_url}" alt="Foto"
|
||||
style="width:100%;border-radius:var(--radius-md);margin-bottom:var(--space-4)">`
|
||||
: ''}
|
||||
|
||||
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-3)">
|
||||
<span class="badge badge-danger">🐕 ${_escape(r.name)}</span>
|
||||
${r.rasse ? `<span class="badge">${_escape(r.rasse)}</span>` : ''}
|
||||
</div>
|
||||
|
||||
<p style="white-space:pre-wrap;margin-bottom:var(--space-3)">
|
||||
${_escape(r.beschreibung)}
|
||||
</p>
|
||||
|
||||
<div style="font-size:var(--text-sm);color:var(--c-text-secondary);
|
||||
margin-bottom:var(--space-4);line-height:1.8">
|
||||
<div>📍 ${r.lat.toFixed(5)}, ${r.lon.toFixed(5)}${distStr ? ' (' + distStr + ' entfernt)' : ''}</div>
|
||||
<div>📅 Gemeldet: ${_fmtDate(r.created_at)}</div>
|
||||
${r.melder_name ? `<div>👤 Gemeldet von: ${_escape(r.melder_name.split(' ')[0])}</div>` : ''}
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap">
|
||||
<button class="btn btn-secondary flex-1" id="detail-lost-map">🗺️ Auf Karte</button>
|
||||
${isOwn || isAdmin
|
||||
? `<button class="btn btn-nature flex-1" id="detail-lost-found">🎉 Gefunden!</button>`
|
||||
: ''}
|
||||
${isOwn || isAdmin
|
||||
? `<button class="btn btn-danger flex-1" id="detail-lost-delete">🗑 Löschen</button>`
|
||||
: ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
UI.modal.open({ title: `🔍 ${_escape(r.name)} wird vermisst`, body });
|
||||
|
||||
document.getElementById('detail-lost-map')?.addEventListener('click', () => {
|
||||
UI.modal.close();
|
||||
if (_map) {
|
||||
_map.setView([r.lat, r.lon], 16);
|
||||
document.getElementById('lost-map')
|
||||
?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
const marker = _markers[_reports.findIndex(x => x.id === r.id)];
|
||||
marker?.openPopup();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('detail-lost-found')?.addEventListener('click', () => {
|
||||
_showFoundDialog(r);
|
||||
});
|
||||
|
||||
document.getElementById('detail-lost-delete')?.addEventListener('click', async () => {
|
||||
if (!confirm(`Meldung für ${r.name} wirklich löschen?`)) return;
|
||||
try {
|
||||
await API.lost.delete(r.id);
|
||||
_reports = _reports.filter(x => x.id !== r.id);
|
||||
_renderMarkers();
|
||||
_renderList();
|
||||
_updateBadge(_reports.length);
|
||||
UI.modal.close();
|
||||
UI.toast.success('Meldung gelöscht.');
|
||||
} catch (err) {
|
||||
UI.toast.error(err.message || 'Fehler beim Löschen.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// GEFUNDEN-DIALOG
|
||||
// ----------------------------------------------------------
|
||||
function _showFoundDialog(r) {
|
||||
UI.modal.open({
|
||||
title: `🎉 ${_escape(r.name)} gefunden?`,
|
||||
body: `
|
||||
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-4)">
|
||||
Wurde ${_escape(r.name)} wiedergefunden? Die Meldung wird als
|
||||
abgeschlossen markiert und aus der Liste entfernt.
|
||||
</p>
|
||||
`,
|
||||
footer: `
|
||||
<button class="btn btn-secondary" id="found-cancel">Abbrechen</button>
|
||||
<button class="btn btn-nature" id="found-confirm">🎉 Ja, gefunden!</button>
|
||||
`,
|
||||
});
|
||||
|
||||
document.getElementById('found-cancel')
|
||||
?.addEventListener('click', UI.modal.close);
|
||||
|
||||
document.getElementById('found-confirm')?.addEventListener('click', async () => {
|
||||
const btn = document.getElementById('found-confirm');
|
||||
await UI.asyncButton(btn, async () => {
|
||||
await API.lost.markFound(r.id);
|
||||
_reports = _reports.filter(x => x.id !== r.id);
|
||||
_renderMarkers();
|
||||
_renderList();
|
||||
_updateBadge(_reports.length);
|
||||
UI.modal.close();
|
||||
UI.toast.success(`${r.name} ist wieder da! 🎉`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// MELDE-FORMULAR
|
||||
// ----------------------------------------------------------
|
||||
function _showReportForm() {
|
||||
if (!_appState.user) {
|
||||
UI.toast.warning('Bitte zuerst anmelden, um eine Meldung abzuschicken.');
|
||||
App.navigate('settings');
|
||||
return;
|
||||
}
|
||||
|
||||
// Eigene registrierte Hunde für Dropdown
|
||||
const dogs = _appState.dogs || [];
|
||||
const dogOpts = dogs.length > 0
|
||||
? `<option value="">— kein registrierter Hund —</option>` +
|
||||
dogs.map(d => `<option value="${d.id}">${_escape(d.name)}${d.rasse ? ' (' + _escape(d.rasse) + ')' : ''}</option>`).join('')
|
||||
: '';
|
||||
|
||||
const body = `
|
||||
<form id="lost-form" autocomplete="off">
|
||||
|
||||
${dogs.length > 0 ? `
|
||||
<div class="form-group">
|
||||
<label class="form-label">
|
||||
Registrierter Hund
|
||||
<span style="color:var(--c-text-secondary)">(optional)</span>
|
||||
</label>
|
||||
<select class="form-control" name="dog_id" id="lf-dog-select">
|
||||
${dogOpts}
|
||||
</select>
|
||||
</div>` : ''}
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Name des Hundes *</label>
|
||||
<input class="form-control" type="text" name="name" id="lf-name"
|
||||
placeholder="z. B. Bello" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">
|
||||
Rasse
|
||||
<span style="color:var(--c-text-secondary)">(optional)</span>
|
||||
</label>
|
||||
<input class="form-control" type="text" name="rasse"
|
||||
placeholder="z. B. Labrador">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Beschreibung *</label>
|
||||
<textarea class="form-control" name="beschreibung" rows="3"
|
||||
placeholder="Farbe, Merkmale, wo zuletzt gesehen, Halsband, …"
|
||||
required></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Standort (letzter bekannter Ort)</label>
|
||||
<div style="display:flex;gap:var(--space-2);align-items:center">
|
||||
<input class="form-control" type="text" id="lf-lat-disp"
|
||||
placeholder="Breite" readonly style="flex:1">
|
||||
<input class="form-control" type="text" id="lf-lon-disp"
|
||||
placeholder="Länge" readonly style="flex:1">
|
||||
<button type="button" class="btn btn-secondary" id="lf-gps-btn"
|
||||
title="GPS-Standort ermitteln">📍</button>
|
||||
</div>
|
||||
<input type="hidden" name="lat" id="lf-lat">
|
||||
<input type="hidden" name="lon" id="lf-lon">
|
||||
<small id="lf-gps-hint" style="color:var(--c-text-secondary)">
|
||||
${_userPos
|
||||
? '✅ Aktueller Standort vorausgefüllt'
|
||||
: 'GPS-Button drücken um Standort zu ermitteln'}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">
|
||||
Foto
|
||||
<span style="color:var(--c-text-secondary)">(optional)</span>
|
||||
</label>
|
||||
<input class="form-control" type="file" name="photo"
|
||||
accept="image/*" capture="environment">
|
||||
<img id="lf-photo-preview"
|
||||
style="display:none;width:100%;max-height:200px;object-fit:cover;
|
||||
border-radius:var(--radius-md);margin-top:var(--space-2)">
|
||||
</div>
|
||||
|
||||
</form>
|
||||
`;
|
||||
|
||||
const footer = `
|
||||
<button type="button" class="btn btn-secondary flex-1" id="lf-cancel">Abbrechen</button>
|
||||
<button type="submit" form="lost-form" class="btn btn-primary flex-1">
|
||||
🔍 Meldung abschicken
|
||||
</button>
|
||||
`;
|
||||
|
||||
UI.modal.open({ title: '🔍 Hund vermisst melden', body, footer });
|
||||
|
||||
// Standort vorausfüllen
|
||||
if (_userPos) {
|
||||
document.getElementById('lf-lat').value = _userPos.lat;
|
||||
document.getElementById('lf-lon').value = _userPos.lon;
|
||||
document.getElementById('lf-lat-disp').value = _userPos.lat.toFixed(6);
|
||||
document.getElementById('lf-lon-disp').value = _userPos.lon.toFixed(6);
|
||||
}
|
||||
|
||||
// Wenn registrierter Hund gewählt → Name+Rasse vorausfüllen
|
||||
document.getElementById('lf-dog-select')?.addEventListener('change', e => {
|
||||
const dogId = parseInt(e.target.value);
|
||||
const dog = dogs.find(d => d.id === dogId);
|
||||
if (dog) {
|
||||
document.getElementById('lf-name').value = dog.name;
|
||||
const rasseInput = document.querySelector('#lost-form [name="rasse"]');
|
||||
if (rasseInput && dog.rasse) rasseInput.value = dog.rasse;
|
||||
}
|
||||
});
|
||||
|
||||
// GPS-Button
|
||||
document.getElementById('lf-gps-btn')?.addEventListener('click', async () => {
|
||||
const btn = document.getElementById('lf-gps-btn');
|
||||
UI.setLoading(btn, true);
|
||||
try {
|
||||
const pos = await API.getLocation({ timeout: 10000, enableHighAccuracy: true });
|
||||
document.getElementById('lf-lat').value = pos.lat;
|
||||
document.getElementById('lf-lon').value = pos.lon;
|
||||
document.getElementById('lf-lat-disp').value = pos.lat.toFixed(6);
|
||||
document.getElementById('lf-lon-disp').value = pos.lon.toFixed(6);
|
||||
document.getElementById('lf-gps-hint').textContent = '✅ Standort aktualisiert';
|
||||
_userPos = pos;
|
||||
} catch {
|
||||
UI.toast.error('GPS-Standort konnte nicht ermittelt werden.');
|
||||
}
|
||||
UI.setLoading(btn, false);
|
||||
});
|
||||
|
||||
// Foto-Vorschau
|
||||
const photoInput = document.querySelector('#lost-form [name="photo"]');
|
||||
const photoPreview = document.getElementById('lf-photo-preview');
|
||||
if (photoInput && photoPreview) {
|
||||
UI.setupPhotoPreview(photoInput, photoPreview);
|
||||
photoInput.addEventListener('change', () => {
|
||||
photoPreview.style.display = photoInput.files[0] ? 'block' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('lf-cancel')
|
||||
?.addEventListener('click', UI.modal.close);
|
||||
|
||||
// Formular absenden
|
||||
document.getElementById('lost-form')?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const submitBtn = document.querySelector('[form="lost-form"][type="submit"]') ||
|
||||
e.target.querySelector('[type="submit"]');
|
||||
const fd = UI.formData(e.target);
|
||||
|
||||
if (!fd.lat || !fd.lon) {
|
||||
UI.toast.warning('Bitte zuerst den GPS-Standort ermitteln (📍).');
|
||||
return;
|
||||
}
|
||||
if (!fd.name?.trim()) {
|
||||
UI.toast.warning('Bitte den Namen des Hundes eingeben.');
|
||||
return;
|
||||
}
|
||||
|
||||
await UI.asyncButton(submitBtn, async () => {
|
||||
const payload = {
|
||||
name : fd.name.trim(),
|
||||
rasse : fd.rasse?.trim() || null,
|
||||
beschreibung : fd.beschreibung?.trim() || '',
|
||||
lat : parseFloat(fd.lat),
|
||||
lon : parseFloat(fd.lon),
|
||||
dog_id : fd.dog_id ? parseInt(fd.dog_id) : null,
|
||||
};
|
||||
|
||||
const created = await API.lost.report(payload);
|
||||
|
||||
// Foto hochladen
|
||||
if (photoInput?.files[0]) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', photoInput.files[0]);
|
||||
const media = await API.lost.uploadFoto(created.id, formData);
|
||||
created.foto_url = media.foto_url;
|
||||
} catch {
|
||||
UI.toast.warning('Meldung erstellt — Foto konnte nicht hochgeladen werden.');
|
||||
}
|
||||
}
|
||||
|
||||
// Distanz client-seitig berechnen
|
||||
created.distanz_m = _userPos
|
||||
? Math.round(_haversine(_userPos.lat, _userPos.lon, created.lat, created.lon))
|
||||
: 0;
|
||||
|
||||
_reports.unshift(created);
|
||||
_renderMarkers();
|
||||
_renderList();
|
||||
_updateBadge(_reports.length);
|
||||
UI.toast.success('Hund als vermisst gemeldet. Wir drücken die Daumen!');
|
||||
UI.modal.close();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// BADGE
|
||||
// ----------------------------------------------------------
|
||||
function _updateBadge(count) {
|
||||
const b = document.getElementById('lost-badge');
|
||||
if (b) { b.textContent = count; b.style.display = count > 0 ? '' : 'none'; }
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// HELPER
|
||||
// ----------------------------------------------------------
|
||||
function _haversine(lat1, lon1, lat2, lon2) {
|
||||
const R = 6_371_000;
|
||||
const p1 = lat1 * Math.PI / 180;
|
||||
const p2 = lat2 * Math.PI / 180;
|
||||
const dp = (lat2 - lat1) * Math.PI / 180;
|
||||
const dl = (lon2 - lon1) * Math.PI / 180;
|
||||
const a = Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2;
|
||||
return 2 * R * Math.asin(Math.sqrt(a));
|
||||
}
|
||||
|
||||
function _fmtDate(isoStr) {
|
||||
if (!isoStr) return '';
|
||||
const d = new Date(isoStr.replace(' ', 'T'));
|
||||
return d.toLocaleDateString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
function _escape(str) {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// PUBLIC
|
||||
// ----------------------------------------------------------
|
||||
return { init, refresh, openNew };
|
||||
|
||||
})();
|
||||
|
|
@ -28,6 +28,8 @@ window.Page_map = (() => {
|
|||
let _recStartTime = null;
|
||||
let _recTimerInt = null;
|
||||
let _recPolyline = null;
|
||||
let _pocketOverlay = null;
|
||||
let _pocketHideTimer = null;
|
||||
let _recMarker = null;
|
||||
let _recWatchId = null;
|
||||
|
||||
|
|
@ -71,22 +73,22 @@ window.Page_map = (() => {
|
|||
|
||||
// z: zIndexOffset — höher = weiter oben bei Überlappung
|
||||
const TYPEN = {
|
||||
restaurant: { icon: '🍽️', label: 'Restaurant', color: '#F97316', z: 10 },
|
||||
freilauf: { icon: '🐕', label: 'Freilauf', color: '#22C55E', z: 20 },
|
||||
shop: { icon: '🛒', label: 'Shop', color: '#3B82F6', z: 15 },
|
||||
kotbeutel: { icon: '🧻', label: 'Kotbeutel', color: '#6B7280', z: 5 },
|
||||
tierarzt: { icon: '🩺', label: 'Tierarzt', color: '#EF4444', z: 40 },
|
||||
hundeschule: { icon: '🎓', label: 'Hundeschule', color: '#8B5CF6', z: 30 },
|
||||
poison: { icon: '⚠️', label: 'Giftköder', color: '#DC2626', z: 100 },
|
||||
muell: { icon: '🗑️', label: 'Mülleimer', color: '#78716C', z: -20 },
|
||||
dog_park: { icon: '🌿', label: 'Hundewiese', color: '#15803D', z: 5 },
|
||||
wasser: { icon: '💧', label: 'Wasserstelle', color: '#0EA5E9', z: 35 },
|
||||
bank: { icon: '🪑', label: 'Bank', color: '#92400E', z: -30 },
|
||||
giftkoeder: { icon: '☠️', label: 'Giftköder', color: '#DC2626', z: 80 },
|
||||
gefahr: { icon: '⚠️', label: 'Gefahr', color: '#F59E0B', z: 60 },
|
||||
parkplatz: { icon: '🅿️', label: 'Parkplatz', color: '#2563EB', z: 5 },
|
||||
treffpunkt: { icon: '🤝', label: 'Treffpunkt', color: '#7C3AED', z: 25 },
|
||||
community: { icon: '📌', label: 'Sonstiges', color: '#F59E0B', z: 30 },
|
||||
restaurant: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#fork-knife"></use></svg>', label: 'Restaurant', color: '#F97316', z: 10 },
|
||||
freilauf: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>', label: 'Freilauf', color: '#22C55E', z: 20 },
|
||||
shop: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#shopping-cart"></use></svg>', label: 'Shop', color: '#3B82F6', z: 15 },
|
||||
kotbeutel: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>', label: 'Kotbeutel', color: '#6B7280', z: 5 },
|
||||
tierarzt: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>', label: 'Tierarzt', color: '#EF4444', z: 40 },
|
||||
hundeschule: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#graduation-cap"></use></svg>', label: 'Hundeschule', color: '#8B5CF6', z: 30 },
|
||||
poison: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg>', label: 'Giftköder', color: '#DC2626', z: 100 },
|
||||
muell: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>', label: 'Mülleimer', color: '#78716C', z: -20 },
|
||||
dog_park: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#leaf"></use></svg>', label: 'Hundewiese', color: '#15803D', z: 5 },
|
||||
wasser: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#drop"></use></svg>', label: 'Wasserstelle', color: '#0EA5E9', z: 35 },
|
||||
bank: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#chair"></use></svg>', label: 'Bank', color: '#92400E', z: -30 },
|
||||
giftkoeder: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#skull"></use></svg>', label: 'Giftköder', color: '#DC2626', z: 80 },
|
||||
gefahr: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg>', label: 'Gefahr', color: '#F59E0B', z: 60 },
|
||||
parkplatz: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#car"></use></svg>', label: 'Parkplatz', color: '#2563EB', z: 5 },
|
||||
treffpunkt: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#handshake"></use></svg>', label: 'Treffpunkt', color: '#7C3AED', z: 25 },
|
||||
community: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg>', label: 'Sonstiges', color: '#F59E0B', z: 30 },
|
||||
};
|
||||
|
||||
// Frontend-Layer → Backend-Typ Mapping
|
||||
|
|
@ -150,7 +152,7 @@ window.Page_map = (() => {
|
|||
|
||||
<div class="map-legend" id="map-legend">
|
||||
<button class="map-legend-btn map-legend-all" id="map-legend-all" title="Alle ein-/ausblenden">
|
||||
☰
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#list"></use></svg>
|
||||
</button>
|
||||
${Object.entries(TYPEN).filter(([k]) => k !== 'giftkoeder').map(([k, t]) => `
|
||||
<button class="map-legend-btn${_visible[k] !== false ? ' active' : ''}" data-layer="${k}" style="--layer-color:${t.color}">
|
||||
|
|
@ -163,22 +165,22 @@ window.Page_map = (() => {
|
|||
|
||||
<!-- Fadenkreuz-Overlay (nur im Placement-Modus sichtbar) -->
|
||||
<div class="map-crosshair" id="map-crosshair">
|
||||
<div class="map-crosshair-pin">📍</div>
|
||||
<div class="map-crosshair-pin"><svg class="ph-icon" aria-hidden="true" style="width:28px;height:28px"><use href="/icons/phosphor.svg#map-pin"></use></svg></div>
|
||||
<div class="map-crosshair-shadow"></div>
|
||||
</div>
|
||||
<div class="map-place-bar" id="map-place-bar">
|
||||
<span class="map-place-hint">Karte verschieben · Pin landet genau hier</span>
|
||||
<div class="map-place-btns">
|
||||
<button class="btn btn-secondary" id="map-place-cancel">Abbrechen</button>
|
||||
<button class="btn btn-primary" id="map-place-confirm">📌 Hier platzieren</button>
|
||||
<button class="btn btn-primary" id="map-place-confirm"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg> Hier platzieren</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="map-fabs">
|
||||
<button class="map-fab map-fab--rec" id="map-rec-btn" title="Route aufzeichnen">🔴</button>
|
||||
<button class="map-fab map-fab--offline" id="map-offline-btn" title="Karte offline speichern">💾</button>
|
||||
<button class="map-fab map-fab--pin" id="map-pin-btn" title="Marker setzen">📌</button>
|
||||
<button class="map-fab" id="map-locate-btn" title="Meinen Standort">📍</button>
|
||||
<button class="map-fab map-fab--rec" id="map-rec-btn" title="Route aufzeichnen"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#path"></use></svg></button>
|
||||
<button class="map-fab map-fab--offline" id="map-offline-btn" title="Karte offline speichern"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#floppy-disk"></use></svg></button>
|
||||
<button class="map-fab map-fab--pin" id="map-pin-btn" title="Marker setzen"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg></button>
|
||||
<button class="map-fab" id="map-locate-btn" title="Meinen Standort"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg></button>
|
||||
</div>
|
||||
|
||||
<div class="map-statusbar" id="map-statusbar">
|
||||
|
|
@ -203,11 +205,11 @@ window.Page_map = (() => {
|
|||
</div>
|
||||
</div>
|
||||
<div class="map-rec-actions">
|
||||
<button class="btn btn-secondary map-rec-action-btn" id="rec-panel-pause">⏸ Pause</button>
|
||||
<button class="btn btn-danger map-rec-action-btn" id="rec-panel-stop">⏹ Speichern</button>
|
||||
<button class="btn btn-secondary map-rec-action-btn" id="rec-panel-pause">Pause</button>
|
||||
<button class="btn btn-danger map-rec-action-btn" id="rec-panel-stop"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#floppy-disk"></use></svg> Speichern</button>
|
||||
</div>
|
||||
<div class="map-rec-hint" id="map-rec-hint">
|
||||
📵 Bildschirm bleibt aktiv — GPS läuft
|
||||
Bildschirm bleibt aktiv — GPS läuft
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -512,7 +514,7 @@ window.Page_map = (() => {
|
|||
html: `<div class="poison-marker">
|
||||
<div class="poison-ring"></div>
|
||||
<div class="poison-ring"></div>
|
||||
<div class="poison-dot">☠️</div>
|
||||
<div class="poison-dot"><svg class="ph-icon" aria-hidden="true" style="width:20px;height:20px"><use href="/icons/phosphor.svg#skull"></use></svg></div>
|
||||
</div>`,
|
||||
iconSize: [48, 48],
|
||||
iconAnchor: [24, 24],
|
||||
|
|
@ -562,11 +564,11 @@ window.Page_map = (() => {
|
|||
: `<button class="btn btn-secondary btn-sm" id="mp-action">Als ungültig melden</button>`;
|
||||
|
||||
const openHours = poi.opening_hours
|
||||
? `<div style="font-size:11px;color:#555;margin-bottom:4px">🕐 ${poi.opening_hours}</div>` : '';
|
||||
? `<div style="font-size:11px;color:#555;margin-bottom:4px"><svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#clock"></use></svg> ${poi.opening_hours}</div>` : '';
|
||||
const phone = poi.phone
|
||||
? `<div style="font-size:11px;margin-bottom:4px"><a href="tel:${poi.phone}" style="color:var(--c-primary);text-decoration:none">📞 ${poi.phone}</a></div>` : '';
|
||||
? `<div style="font-size:11px;margin-bottom:4px"><a href="tel:${poi.phone}" style="color:var(--c-primary);text-decoration:none">${poi.phone}</a></div>` : '';
|
||||
const website = poi.website
|
||||
? `<div style="font-size:11px;margin-bottom:6px"><a href="${poi.website}" target="_blank" rel="noopener" style="color:var(--c-primary);text-decoration:none">🌐 Website</a></div>` : '';
|
||||
? `<div style="font-size:11px;margin-bottom:6px"><a href="${poi.website}" target="_blank" rel="noopener" style="color:var(--c-primary);text-decoration:none"><svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#arrow-square-out"></use></svg> Website</a></div>` : '';
|
||||
|
||||
marker.bindPopup(`
|
||||
<div style="min-width:170px;max-width:240px">
|
||||
|
|
@ -575,8 +577,8 @@ window.Page_map = (() => {
|
|||
${openHours}${phone}${website}
|
||||
<div style="font-size:11px;color:#999;margin-bottom:10px">
|
||||
${isUser
|
||||
? `📌 Community-Pin${poi.username ? ' · <b>' + poi.username + '</b>' : ''}`
|
||||
: '🗺️ OpenStreetMap'}
|
||||
? `<svg class="ph-icon" aria-hidden="true" style="width:11px;height:11px"><use href="/icons/phosphor.svg#push-pin"></use></svg> Community-Pin${poi.username ? ' · <b>' + poi.username + '</b>' : ''}`
|
||||
: '<svg class="ph-icon" aria-hidden="true" style="width:11px;height:11px"><use href="/icons/phosphor.svg#map-trifold"></use></svg> OpenStreetMap'}
|
||||
</div>
|
||||
${actionBtn}
|
||||
</div>
|
||||
|
|
@ -618,7 +620,7 @@ window.Page_map = (() => {
|
|||
_placingMarker = false;
|
||||
const btn = document.getElementById('map-pin-btn');
|
||||
btn?.classList.remove('active');
|
||||
btn && (btn.textContent = '\uD83D\uDCCC');
|
||||
btn && (btn.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg>');
|
||||
document.getElementById('map-crosshair')?.classList.remove('active', 'dragging');
|
||||
document.getElementById('map-place-bar')?.classList.remove('active');
|
||||
_tempMarker?.remove();
|
||||
|
|
@ -626,14 +628,14 @@ window.Page_map = (() => {
|
|||
}
|
||||
|
||||
const PIN_TYPES = [
|
||||
{ type: 'giftkoeder', icon: '☠️', label: 'Giftköder', color: '#DC2626' }, // ← wichtigster Typ, immer oben
|
||||
{ type: 'waste_basket', icon: '🗑️', label: 'Mülleimer', color: '#78716C' },
|
||||
{ type: 'kotbeutel', icon: '🧻', label: 'Kotbeutel', color: '#6B7280' },
|
||||
{ type: 'drinking_water', icon: '💧', label: 'Wasserstelle', color: '#0EA5E9' },
|
||||
{ type: 'dog_park', icon: '🌿', label: 'Hundewiese', color: '#15803D' },
|
||||
{ type: 'parkplatz', icon: '🅿️', label: 'Parkplatz', color: '#2563EB' },
|
||||
{ type: 'treffpunkt', icon: '🤝', label: 'Treffpunkt', color: '#7C3AED' },
|
||||
{ type: 'sonstiges', icon: '📌', label: 'Sonstiges', color: '#F59E0B' },
|
||||
{ type: 'giftkoeder', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#skull"></use></svg>', label: 'Giftköder', color: '#DC2626' }, // ← wichtigster Typ, immer oben
|
||||
{ type: 'waste_basket', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>', label: 'Mülleimer', color: '#78716C' },
|
||||
{ type: 'kotbeutel', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>', label: 'Kotbeutel', color: '#6B7280' },
|
||||
{ type: 'drinking_water', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#drop"></use></svg>', label: 'Wasserstelle', color: '#0EA5E9' },
|
||||
{ type: 'dog_park', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#leaf"></use></svg>', label: 'Hundewiese', color: '#15803D' },
|
||||
{ type: 'parkplatz', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#car"></use></svg>', label: 'Parkplatz', color: '#2563EB' },
|
||||
{ type: 'treffpunkt', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#handshake"></use></svg>', label: 'Treffpunkt', color: '#7C3AED' },
|
||||
{ type: 'sonstiges', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg>', label: 'Sonstiges', color: '#F59E0B' },
|
||||
];
|
||||
|
||||
function _confirmPlacement(latlng) {
|
||||
|
|
@ -645,7 +647,7 @@ window.Page_map = (() => {
|
|||
let _selectedType = 'giftkoeder';
|
||||
|
||||
UI.modal.open({
|
||||
title: '📌 Marker setzen',
|
||||
title: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg> Marker setzen',
|
||||
body: `
|
||||
<form id="poi-form" class="flex flex-col gap-3">
|
||||
<div>
|
||||
|
|
@ -930,7 +932,7 @@ window.Page_map = (() => {
|
|||
navigator.serviceWorker.removeEventListener('message', onMessage);
|
||||
if (btn) btn.classList.remove('loading');
|
||||
_setOsmStatus('');
|
||||
UI.toast.success(`\u2705 ${total} Kacheln offline gespeichert!`);
|
||||
UI.toast.success(`${total} Kacheln offline gespeichert!`);
|
||||
} else {
|
||||
_setOsmStatus(`Offline: ${done} / ${total} Kacheln…`);
|
||||
}
|
||||
|
|
@ -940,6 +942,92 @@ window.Page_map = (() => {
|
|||
navigator.serviceWorker.controller.postMessage({ type: 'CACHE_TILES', urls });
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Pocket-Modus Overlay
|
||||
// ----------------------------------------------------------
|
||||
function _showPocketOverlay() {
|
||||
if (_pocketOverlay) return;
|
||||
const el = document.createElement('div');
|
||||
el.id = 'pocket-overlay';
|
||||
el.innerHTML = `
|
||||
<div class="po-status" id="po-status">GPS läuft</div>
|
||||
<div class="po-time" id="po-time">0:00</div>
|
||||
<div class="po-dist" id="po-dist">0.00 km</div>
|
||||
<div class="po-hint">Tippen für Steuerung</div>
|
||||
<div class="po-controls" id="po-controls">
|
||||
<button class="po-btn" id="po-pause">⏸ Pause</button>
|
||||
<button class="po-btn po-btn--stop" id="po-stop">⏹ Stopp</button>
|
||||
</div>
|
||||
`;
|
||||
el.style.cssText = `
|
||||
position:fixed;inset:0;z-index:9998;background:#000;
|
||||
display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||||
color:#fff;font-family:inherit;user-select:none;
|
||||
`;
|
||||
el.querySelector('.po-status').style.cssText =
|
||||
'font-size:0.85rem;color:#888;margin-bottom:1rem;letter-spacing:0.05em;text-transform:uppercase';
|
||||
el.querySelector('.po-time').style.cssText =
|
||||
'font-size:4.5rem;font-weight:700;letter-spacing:-0.02em;line-height:1';
|
||||
el.querySelector('.po-dist').style.cssText =
|
||||
'font-size:1.5rem;color:#aaa;margin-top:0.5rem';
|
||||
el.querySelector('.po-hint').style.cssText =
|
||||
'font-size:0.75rem;color:#444;margin-top:2.5rem';
|
||||
const ctrl = el.querySelector('.po-controls');
|
||||
ctrl.style.cssText =
|
||||
'display:none;gap:1rem;margin-top:2rem;flex-direction:row';
|
||||
el.querySelectorAll('.po-btn').forEach(b => {
|
||||
b.style.cssText =
|
||||
'padding:0.75rem 1.5rem;border:1px solid #555;border-radius:0.75rem;' +
|
||||
'background:#111;color:#fff;font-size:1rem;cursor:pointer';
|
||||
});
|
||||
el.querySelector('.po-btn--stop').style.cssText +=
|
||||
'border-color:#c0392b;color:#e74c3c';
|
||||
|
||||
// Tippen → Controls 4s einblenden, dann ausblenden
|
||||
el.addEventListener('click', e => {
|
||||
if (e.target.closest('#po-controls')) return;
|
||||
ctrl.style.display = 'flex';
|
||||
el.querySelector('.po-hint').style.color = '#666';
|
||||
clearTimeout(_pocketHideTimer);
|
||||
_pocketHideTimer = setTimeout(() => {
|
||||
ctrl.style.display = 'none';
|
||||
el.querySelector('.po-hint').style.color = '#444';
|
||||
}, 4000);
|
||||
});
|
||||
|
||||
el.querySelector('#po-pause').addEventListener('click', () => {
|
||||
_togglePause();
|
||||
el.querySelector('#po-pause').textContent =
|
||||
_recPaused ? '▶ Weiter' : '⏸ Pause';
|
||||
el.querySelector('#po-status').textContent =
|
||||
_recPaused ? 'Pausiert' : 'GPS läuft';
|
||||
});
|
||||
el.querySelector('#po-stop').addEventListener('click', () => {
|
||||
_hidePocketOverlay();
|
||||
_stopRecording();
|
||||
});
|
||||
|
||||
document.body.appendChild(el);
|
||||
_pocketOverlay = el;
|
||||
}
|
||||
|
||||
function _hidePocketOverlay() {
|
||||
clearTimeout(_pocketHideTimer);
|
||||
_pocketOverlay?.remove();
|
||||
_pocketOverlay = null;
|
||||
}
|
||||
|
||||
function _updatePocketOverlay() {
|
||||
if (!_pocketOverlay) return;
|
||||
const elapsed = Math.floor((Date.now() - _recStartTime) / 1000);
|
||||
const mm = String(Math.floor(elapsed / 60)).padStart(1, '0');
|
||||
const ss = String(elapsed % 60).padStart(2, '0');
|
||||
const timeEl = _pocketOverlay.querySelector('#po-time');
|
||||
const distEl = _pocketOverlay.querySelector('#po-dist');
|
||||
if (timeEl) timeEl.textContent = `${mm}:${ss}`;
|
||||
if (distEl) distEl.textContent = `${_recDistKm.toFixed(2)} km`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// GPS-Aufzeichnung
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -966,7 +1054,7 @@ window.Page_map = (() => {
|
|||
|
||||
// FAB umschalten
|
||||
const btn = document.getElementById('map-rec-btn');
|
||||
if (btn) { btn.textContent = '⏹'; btn.classList.add('recording'); }
|
||||
if (btn) { btn.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#floppy-disk"></use></svg>'; btn.classList.add('recording'); }
|
||||
|
||||
// Aufzeichnungs-Panel einblenden
|
||||
const panel = document.getElementById('map-rec-panel');
|
||||
|
|
@ -978,8 +1066,8 @@ window.Page_map = (() => {
|
|||
await _acquireWakeLock();
|
||||
const hint = document.getElementById('map-rec-hint');
|
||||
if (hint) hint.textContent = _wakeLock
|
||||
? '📵 Bildschirm bleibt aktiv — GPS läuft'
|
||||
: '⚠️ Bildschirm-Lock nicht unterstützt — Bildschirm aktiv lassen';
|
||||
? 'Bildschirm bleibt aktiv — GPS läuft'
|
||||
: 'Bildschirm-Lock nicht unterstützt — Bildschirm aktiv lassen';
|
||||
|
||||
// Sichtbarkeit: Wake Lock bei Tab-Wechsel neu anfordern
|
||||
document.addEventListener('visibilitychange', _onVisibilityChange);
|
||||
|
|
@ -1003,7 +1091,12 @@ window.Page_map = (() => {
|
|||
() => {},
|
||||
{ enableHighAccuracy: true, maximumAge: 0, timeout: 10000 }
|
||||
);
|
||||
UI.toast.success('Aufzeichnung gestartet — los geht\'s! 🐕');
|
||||
UI.toast.success('Aufzeichnung gestartet — los geht\'s!');
|
||||
|
||||
// Pocket-Modus aktivieren wenn in Einstellungen eingeschaltet
|
||||
if (localStorage.getItem('by_pocket_mode') === 'true') {
|
||||
setTimeout(_showPocketOverlay, 800); // kurz warten damit Toast sichtbar war
|
||||
}
|
||||
}
|
||||
|
||||
async function _onVisibilityChange() {
|
||||
|
|
@ -1074,6 +1167,7 @@ window.Page_map = (() => {
|
|||
if (distEl) distEl.textContent = _recDistKm.toFixed(2);
|
||||
if (timeEl) timeEl.textContent = `${mm}:${ss}`;
|
||||
if (paceEl) paceEl.textContent = pace;
|
||||
_updatePocketOverlay();
|
||||
}
|
||||
|
||||
function _stopRecording() {
|
||||
|
|
@ -1081,10 +1175,11 @@ window.Page_map = (() => {
|
|||
if (_recTimerInt) { clearInterval(_recTimerInt); _recTimerInt = null; }
|
||||
_recActive = false;
|
||||
_releaseWakeLock();
|
||||
_hidePocketOverlay();
|
||||
document.removeEventListener('visibilitychange', _onVisibilityChange);
|
||||
|
||||
const btn = document.getElementById('map-rec-btn');
|
||||
if (btn) { btn.textContent = '🔴'; btn.classList.remove('recording'); }
|
||||
if (btn) { btn.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#path"></use></svg>'; btn.classList.remove('recording'); }
|
||||
|
||||
const panel = document.getElementById('map-rec-panel');
|
||||
if (panel) panel.classList.remove('active', 'paused');
|
||||
|
|
@ -1100,16 +1195,34 @@ window.Page_map = (() => {
|
|||
_showRecSaveModal(_recTrack, _recDistKm, dauMin);
|
||||
}
|
||||
|
||||
async function _prefillRouteName(track, distKm) {
|
||||
const nameInput = document.querySelector('#rec-save-form [name="name"]');
|
||||
if (!nameInput || nameInput.value) return;
|
||||
const pt = track[0];
|
||||
const date = new Date().toLocaleDateString('de-DE', { day:'2-digit', month:'2-digit', year:'numeric' });
|
||||
const km = distKm.toFixed(1);
|
||||
let ort = '';
|
||||
try {
|
||||
const r = await fetch(`https://nominatim.openstreetmap.org/reverse?lat=${pt.lat}&lon=${pt.lon}&format=json&zoom=13&addressdetails=1&accept-language=de`, { cache: 'no-store' });
|
||||
const data = await r.json();
|
||||
const a = data.address || {};
|
||||
ort = a.village || a.town || a.suburb || a.city_district || a.city || a.municipality || '';
|
||||
} catch {}
|
||||
if (!nameInput.value) nameInput.value = ort
|
||||
? `Gassirunde ${ort} · ${date} · ${km} km`
|
||||
: `Gassirunde · ${date} · ${km} km`;
|
||||
}
|
||||
|
||||
function _showRecSaveModal(track, distKm, dauMin) {
|
||||
const body = `
|
||||
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-4)">
|
||||
🎉 ${track.length} GPS-Punkte · ${distKm.toFixed(2)} km · ca. ${dauMin} min
|
||||
${track.length} GPS-Punkte · ${distKm.toFixed(2)} km · ca. ${dauMin} min
|
||||
</p>
|
||||
<form id="rec-save-form" autocomplete="off">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Name der Route *</label>
|
||||
<input class="form-control" type="text" name="name"
|
||||
placeholder="z.B. Waldspaziergang am See" required>
|
||||
placeholder="Wird automatisch ermittelt…" required>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||
<div class="form-group">
|
||||
|
|
@ -1124,34 +1237,34 @@ window.Page_map = (() => {
|
|||
<label class="form-label">Untergrund</label>
|
||||
<select class="form-control" name="untergrund">
|
||||
<option value="">– unbekannt –</option>
|
||||
<option value="wald">🌲 Wald</option>
|
||||
<option value="asphalt">🛣️ Asphalt</option>
|
||||
<option value="wiese">🌿 Wiese</option>
|
||||
<option value="mix">🔀 Mix</option>
|
||||
<option value="wald">Wald</option>
|
||||
<option value="asphalt">Asphalt</option>
|
||||
<option value="wiese">Wiese</option>
|
||||
<option value="mix">Mix</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Hundetauglichkeit</label>
|
||||
<div class="rk-paw-select" id="rec-paw-select">
|
||||
<button type="button" class="rk-paw-btn" data-val="eingeschränkt">🐾 Eingeschränkt</button>
|
||||
<button type="button" class="rk-paw-btn" data-val="gut">🐾🐾 Gut</button>
|
||||
<button type="button" class="rk-paw-btn selected" data-val="sehr_gut">🐾🐾🐾 Sehr gut</button>
|
||||
<button type="button" class="rk-paw-btn" data-val="premium">🐾🐾🐾🐾 Premium</button>
|
||||
<button type="button" class="rk-paw-btn" data-val="eingeschränkt"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg> Eingeschränkt</button>
|
||||
<button type="button" class="rk-paw-btn" data-val="gut"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg> Gut</button>
|
||||
<button type="button" class="rk-paw-btn selected" data-val="sehr_gut"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg> Sehr gut</button>
|
||||
<button type="button" class="rk-paw-btn" data-val="premium"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg> Premium</button>
|
||||
</div>
|
||||
<input type="hidden" name="hunde_tauglichkeit" id="rec-paw-val" value="sehr_gut">
|
||||
</div>
|
||||
<div class="form-group" style="display:flex;gap:var(--space-4)">
|
||||
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
|
||||
<input type="checkbox" name="schatten"> 🌳 Viel Schatten
|
||||
<input type="checkbox" name="schatten"> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#tree"></use></svg> Viel Schatten
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
|
||||
<input type="checkbox" name="leine_empfohlen"> 🔗 Leine empfohlen
|
||||
<input type="checkbox" name="leine_empfohlen"> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#tag"></use></svg> Leine empfohlen
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
|
||||
<input type="checkbox" name="is_public" checked> 🌍 Öffentlich (von allen sichtbar)
|
||||
<input type="checkbox" name="is_public" checked> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#globe"></use></svg> Öffentlich (von allen sichtbar)
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
|
|
@ -1164,10 +1277,12 @@ window.Page_map = (() => {
|
|||
|
||||
const footer = `
|
||||
<button type="button" class="btn btn-secondary flex-1" id="rms-discard">Verwerfen</button>
|
||||
<button type="submit" form="rec-save-form" class="btn btn-primary flex-1">💾 Speichern</button>
|
||||
<button type="submit" form="rec-save-form" class="btn btn-primary flex-1"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#floppy-disk"></use></svg> Speichern</button>
|
||||
`;
|
||||
|
||||
UI.modal.open({ title: '🥾 Route benennen', body, footer });
|
||||
UI.modal.open({ title: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#path"></use></svg> Route benennen', body, footer });
|
||||
|
||||
_prefillRouteName(track, distKm); // async, füllt Name-Feld sobald Nominatim antwortet
|
||||
|
||||
document.getElementById('rec-paw-select')?.addEventListener('click', e => {
|
||||
const btn = e.target.closest('.rk-paw-btn');
|
||||
|
|
@ -1204,7 +1319,7 @@ window.Page_map = (() => {
|
|||
UI.modal.close();
|
||||
if (_recPolyline) { _recPolyline.remove(); _recPolyline = null; }
|
||||
if (_recMarker) { _recMarker.remove(); _recMarker = null; }
|
||||
UI.toast.success(`Route „${saved.name}" gespeichert! 🎉`);
|
||||
UI.toast.success(`Route „${saved.name}" gespeichert!`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
409
backend/static/js/pages/movies.js
Normal file
409
backend/static/js/pages/movies.js
Normal file
|
|
@ -0,0 +1,409 @@
|
|||
/* ============================================================
|
||||
BAN YARO — Hunde-Filme
|
||||
Seiten-Modul: Film-Datenbank, Promi-Hunde, Hund des Monats.
|
||||
============================================================ */
|
||||
|
||||
window.Page_movies = (() => {
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// MODUL-STATE
|
||||
// ----------------------------------------------------------
|
||||
let _container = null;
|
||||
let _appState = null;
|
||||
let _filme = [];
|
||||
let _activeTab = 'filme';
|
||||
let _filter = 'alle';
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// INIT
|
||||
// ----------------------------------------------------------
|
||||
async function init(container, appState) {
|
||||
_container = container;
|
||||
_appState = appState;
|
||||
await _render();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// REFRESH
|
||||
// ----------------------------------------------------------
|
||||
async function refresh() {
|
||||
_filme = [];
|
||||
await _render();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// RENDER — Haupt-Layout mit Tabs
|
||||
// ----------------------------------------------------------
|
||||
async function _render() {
|
||||
_container.innerHTML = `
|
||||
<div class="movies-tabs">
|
||||
<button class="movies-tab${_activeTab === 'filme' ? ' movies-tab--active' : ''}" data-tab="filme">${UI.icon('film-slate')} Filme</button>
|
||||
<button class="movies-tab${_activeTab === 'promis' ? ' movies-tab--active' : ''}" data-tab="promis">${UI.icon('star')} Berühmtheiten</button>
|
||||
<button class="movies-tab${_activeTab === 'hdm' ? ' movies-tab--active' : ''}" data-tab="hdm">${UI.icon('paw-print')} Hund des Monats</button>
|
||||
</div>
|
||||
<div id="movies-tab-content"></div>
|
||||
`;
|
||||
|
||||
_container.querySelectorAll('.movies-tab').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
_activeTab = btn.dataset.tab;
|
||||
_container.querySelectorAll('.movies-tab').forEach(b => b.classList.remove('movies-tab--active'));
|
||||
btn.classList.add('movies-tab--active');
|
||||
_renderTab();
|
||||
});
|
||||
});
|
||||
|
||||
await _renderTab();
|
||||
}
|
||||
|
||||
async function _renderTab() {
|
||||
const content = _container.querySelector('#movies-tab-content');
|
||||
if (!content) return;
|
||||
|
||||
content.innerHTML = UI.skeleton(3);
|
||||
|
||||
if (_activeTab === 'filme') await _renderFilme(content);
|
||||
if (_activeTab === 'promis') _renderPromis(content);
|
||||
if (_activeTab === 'hdm') await _renderHundDesMonats(content);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// TAB 1: FILME
|
||||
// ----------------------------------------------------------
|
||||
async function _renderFilme(content) {
|
||||
try {
|
||||
_filme = await API.get('/movies/filme');
|
||||
} catch {
|
||||
content.innerHTML = UI.emptyState({ icon: '🎬', title: 'Filme konnten nicht geladen werden', text: 'Bitte versuche es erneut.' });
|
||||
return;
|
||||
}
|
||||
|
||||
content.innerHTML = `
|
||||
<div class="movies-filter-row">
|
||||
<button class="movies-filter-btn${_filter === 'alle' ? ' movies-filter-btn--active' : ''}" data-filter="alle">Alle</button>
|
||||
<button class="movies-filter-btn${_filter === 'stirbt' ? ' movies-filter-btn--active' : ''}" data-filter="stirbt">😭 Hund stirbt</button>
|
||||
<button class="movies-filter-btn${_filter === 'ueberlebt' ? ' movies-filter-btn--active' : ''}" data-filter="ueberlebt">🐾 Hund überlebt</button>
|
||||
<button class="movies-filter-btn${_filter === 'top' ? ' movies-filter-btn--active' : ''}" data-filter="top">⭐⭐⭐⭐+</button>
|
||||
</div>
|
||||
<div class="movie-grid" id="movie-grid"></div>
|
||||
`;
|
||||
|
||||
content.querySelectorAll('.movies-filter-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
_filter = btn.dataset.filter;
|
||||
content.querySelectorAll('.movies-filter-btn').forEach(b => b.classList.remove('movies-filter-btn--active'));
|
||||
btn.classList.add('movies-filter-btn--active');
|
||||
_renderMovieGrid(content.querySelector('#movie-grid'));
|
||||
});
|
||||
});
|
||||
|
||||
_renderMovieGrid(content.querySelector('#movie-grid'));
|
||||
}
|
||||
|
||||
function _renderMovieGrid(grid) {
|
||||
if (!grid) return;
|
||||
let list = [..._filme];
|
||||
|
||||
if (_filter === 'stirbt') list = list.filter(f => f.stirbt_der_hund);
|
||||
if (_filter === 'ueberlebt') list = list.filter(f => !f.stirbt_der_hund);
|
||||
if (_filter === 'top') list = list.filter(f => f.bewertung_avg >= 4.0);
|
||||
|
||||
if (list.length === 0) {
|
||||
grid.innerHTML = `<div style="grid-column:1/-1;padding:var(--space-8);text-align:center;color:var(--c-text-secondary)">Keine Filme für diesen Filter.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = list.map(f => _movieCard(f)).join('');
|
||||
|
||||
grid.querySelectorAll('.movie-card').forEach(card => {
|
||||
card.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.movie-star-rating')) return;
|
||||
const id = card.dataset.filmId;
|
||||
const film = _filme.find(f => f.id === id);
|
||||
if (film) _openMovieModal(film);
|
||||
});
|
||||
});
|
||||
|
||||
_bindStarRatings(grid);
|
||||
}
|
||||
|
||||
function _movieCard(film) {
|
||||
const stirbt = film.stirbt_der_hund;
|
||||
const tag = stirbt
|
||||
? `<div class="movie-tag-stirbt">⚠️ ACHTUNG: Der Hund stirbt</div>`
|
||||
: `<div class="movie-tag-ueberlebt">✅ Der Hund überlebt</div>`;
|
||||
const stars = _starsHtml(film.bewertung_avg, film.id, film.user_rating, false);
|
||||
|
||||
return `
|
||||
<div class="movie-card" data-film-id="${_esc(film.id)}">
|
||||
<div class="movie-card-emoji">${film.bild_emoji}</div>
|
||||
<div class="movie-card-body">
|
||||
<div class="movie-card-title">${_esc(film.titel)} <span class="movie-card-year">(${film.jahr})</span></div>
|
||||
<div class="movie-card-genre">${_esc(film.genre)}</div>
|
||||
<div class="movie-card-rasse">🐾 ${_esc(film.hund_rasse)}</div>
|
||||
${tag}
|
||||
<div class="movie-card-stars">${stars}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function _openMovieModal(film) {
|
||||
const stirbt = film.stirbt_der_hund;
|
||||
const bannerClass = stirbt ? 'movie-tag-stirbt' : 'movie-tag-ueberlebt';
|
||||
const bannerText = stirbt ? '⚠️ ACHTUNG: Der Hund stirbt!' : '✅ Der Hund überlebt';
|
||||
|
||||
const stars = _starsHtml(film.bewertung_avg, film.id, film.user_rating, true);
|
||||
const loginHint = !_appState.user
|
||||
? `<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-top:var(--space-2)">Anmelden um zu bewerten</p>`
|
||||
: '';
|
||||
|
||||
const body = `
|
||||
<div class="movie-modal-emoji">${film.bild_emoji}</div>
|
||||
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-3)">
|
||||
<span class="badge badge-primary">${_esc(film.genre)}</span>
|
||||
<span class="badge">🐾 ${_esc(film.hund_rasse)}</span>
|
||||
<span class="badge">${film.jahr}</span>
|
||||
</div>
|
||||
<div class="${bannerClass}" style="margin-bottom:var(--space-4);font-size:var(--text-base)">${bannerText}</div>
|
||||
<p style="line-height:1.6;color:var(--c-text);margin-bottom:var(--space-5)">${_esc(film.beschreibung)}</p>
|
||||
<div style="margin-bottom:var(--space-2)">
|
||||
<strong>Community-Bewertung:</strong>
|
||||
</div>
|
||||
<div id="modal-stars-${_esc(film.id)}">${stars}</div>
|
||||
<div id="modal-avg-${_esc(film.id)}" style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-top:var(--space-1)">
|
||||
Ø ${film.bewertung_avg} von ${film.bewertung_cnt || 0} Bewertungen
|
||||
</div>
|
||||
${loginHint}
|
||||
`;
|
||||
|
||||
UI.modal.open({ title: film.titel, body });
|
||||
|
||||
const starsEl = document.getElementById(`modal-stars-${film.id}`);
|
||||
if (starsEl && _appState.user) {
|
||||
_bindStarRatingsEl(starsEl, film.id, true);
|
||||
}
|
||||
}
|
||||
|
||||
function _starsHtml(avg, filmId, userRating, interactive) {
|
||||
const filled = Math.round(avg);
|
||||
const stars = [1,2,3,4,5].map(i => {
|
||||
const active = i <= (userRating || filled) ? ' movie-star--active' : '';
|
||||
return `<span class="movie-star${active}" data-film-id="${_esc(filmId)}" data-val="${i}">★</span>`;
|
||||
}).join('');
|
||||
return `<div class="movie-star-rating" data-film-id="${_esc(filmId)}">${stars} <span class="movie-star-avg">${avg}</span></div>`;
|
||||
}
|
||||
|
||||
function _bindStarRatings(container) {
|
||||
container.querySelectorAll('.movie-star-rating').forEach(el => {
|
||||
_bindStarRatingsEl(el, el.dataset.filmId, false);
|
||||
});
|
||||
}
|
||||
|
||||
function _bindStarRatingsEl(el, filmId, inModal) {
|
||||
if (!_appState.user) return;
|
||||
|
||||
const stars = el.querySelectorAll('.movie-star');
|
||||
|
||||
stars.forEach(star => {
|
||||
star.addEventListener('mouseenter', () => {
|
||||
const val = parseInt(star.dataset.val);
|
||||
stars.forEach((s, i) => s.classList.toggle('movie-star--active', i < val));
|
||||
});
|
||||
|
||||
star.addEventListener('mouseleave', () => {
|
||||
const film = _filme.find(f => f.id === filmId);
|
||||
const cur = film?.user_rating || 0;
|
||||
stars.forEach((s, i) => s.classList.toggle('movie-star--active', i < cur));
|
||||
});
|
||||
|
||||
star.addEventListener('click', async () => {
|
||||
const val = parseInt(star.dataset.val);
|
||||
try {
|
||||
const res = await API.post(`/movies/filme/${filmId}/vote`, { bewertung: val });
|
||||
// Update in _filme array
|
||||
const idx = _filme.findIndex(f => f.id === filmId);
|
||||
if (idx !== -1) {
|
||||
_filme[idx].user_rating = val;
|
||||
_filme[idx].bewertung_avg = res.bewertung_avg;
|
||||
_filme[idx].bewertung_cnt = res.bewertung_cnt;
|
||||
}
|
||||
// Update star display
|
||||
stars.forEach((s, i) => s.classList.toggle('movie-star--active', i < val));
|
||||
const avgEl = el.querySelector('.movie-star-avg') ||
|
||||
(inModal ? document.getElementById(`modal-avg-${filmId}`) : null);
|
||||
if (el.querySelector('.movie-star-avg')) {
|
||||
el.querySelector('.movie-star-avg').textContent = res.bewertung_avg;
|
||||
}
|
||||
if (inModal) {
|
||||
const avgInfo = document.getElementById(`modal-avg-${filmId}`);
|
||||
if (avgInfo) avgInfo.textContent = `Ø ${res.bewertung_avg} von ${res.bewertung_cnt} Bewertungen`;
|
||||
}
|
||||
UI.toast.success('Bewertung gespeichert!');
|
||||
} catch {
|
||||
UI.toast.error('Bewertung konnte nicht gespeichert werden.');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// TAB 2: BERÜHMTHEITEN (hardcoded, kein Backend)
|
||||
// ----------------------------------------------------------
|
||||
const PROMIS = [
|
||||
{ name: "Hachikō", rasse: "Akita Inu", bekannt_fuer: "9 Jahre lang täglich auf seinen verstorbenen Herrchen am Bahnhof Shibuya gewartet. Statue in Tokio.", emoji: "🗿" },
|
||||
{ name: "Rin Tin Tin", rasse: "Deutscher Schäferhund", bekannt_fuer: "Filmhund der 1920er-Jahre. Rettete Warner Bros. vor dem Bankrott. Erster Hundestar Hollywoods.", emoji: "🎬" },
|
||||
{ name: "Laika", rasse: "Mischling", bekannt_fuer: "Erstes Lebewesen im Weltall (Sputnik 2, 1957). Wurde zur sowjetischen Weltraumpionierin.", emoji: "🚀" },
|
||||
{ name: "Endal", rasse: "Labrador", bekannt_fuer: "Assistenzhund in England. Erster Hund der eine EC-Karte am Geldautomaten benutzte.", emoji: "💳" },
|
||||
{ name: "Barry", rasse: "Bernhardiner", bekannt_fuer: "Legendärer Rettungshund der Alpen (1800–1812). Soll 40 Menschen das Leben gerettet haben.", emoji: "🏔️" },
|
||||
{ name: "Greyfriars Bobby", rasse: "Skye Terrier", bekannt_fuer: "14 Jahre lang das Grab seines Herrchens in Edinburgh bewacht. Statue und Pub benannt nach ihm.", emoji: "⛪" },
|
||||
];
|
||||
|
||||
function _renderPromis(content) {
|
||||
content.innerHTML = `
|
||||
<div style="padding:var(--space-2) 0">
|
||||
${PROMIS.map(p => `
|
||||
<div class="movie-promi-card">
|
||||
<div class="movie-promi-emoji">${p.emoji}</div>
|
||||
<div class="movie-promi-body">
|
||||
<div class="movie-promi-name">${_esc(p.name)}</div>
|
||||
<div class="movie-promi-rasse">${_esc(p.rasse)}</div>
|
||||
<div class="movie-promi-text">${_esc(p.bekannt_fuer)}</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// TAB 3: HUND DES MONATS
|
||||
// ----------------------------------------------------------
|
||||
async function _renderHundDesMonats(content) {
|
||||
let data;
|
||||
try {
|
||||
data = await API.get('/movies/hund-des-monats');
|
||||
} catch {
|
||||
content.innerHTML = UI.emptyState({ icon: '🏆', title: 'Fehler beim Laden', text: 'Bitte versuche es erneut.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const [year, month] = data.monat.split('-');
|
||||
const monthName = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' })
|
||||
.format(new Date(+year, +month - 1, 1));
|
||||
|
||||
let voteSection = '';
|
||||
if (_appState.user && _appState.dogs?.length > 0) {
|
||||
const voteCards = _appState.dogs.map(dog => {
|
||||
const isVoted = data.user_vote === dog.id;
|
||||
const av = dog.foto_url
|
||||
? `<img src="${_esc(dog.foto_url)}" alt="${_esc(dog.name)}" class="hdm-vote-av-img">`
|
||||
: `<span class="hdm-vote-av-placeholder">${_esc(dog.name.charAt(0).toUpperCase())}</span>`;
|
||||
return `
|
||||
<div class="hdm-vote-card${isVoted ? ' hdm-vote-card--voted' : ''}" data-dog-id="${dog.id}">
|
||||
<div class="hdm-vote-av">${av}</div>
|
||||
<div class="hdm-vote-name">${_esc(dog.name)}</div>
|
||||
${dog.rasse ? `<div class="hdm-vote-rasse">${_esc(dog.rasse)}</div>` : ''}
|
||||
<button class="btn${isVoted ? ' btn-primary' : ' btn-secondary'} hdm-vote-btn" data-dog-id="${dog.id}">
|
||||
${isVoted ? '✅ Gewählt' : 'Abstimmen'}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
voteSection = `
|
||||
<div class="hdm-section">
|
||||
<h3 class="hdm-section-title">Für welchen deiner Hunde möchtest du abstimmen?</h3>
|
||||
<div class="hdm-vote-grid" id="hdm-vote-grid">${voteCards}</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (!_appState.user) {
|
||||
voteSection = `
|
||||
<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 für deinen Hund abzustimmen.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const topList = data.top.length > 0
|
||||
? data.top.slice(0, 5).map((dog, i) => {
|
||||
const medal = ['🥇','🥈','🥉','4️⃣','5️⃣'][i] || `${i+1}.`;
|
||||
const av = dog.foto_url
|
||||
? `<img src="${_esc(dog.foto_url)}" alt="${_esc(dog.name)}" class="hdm-top-av-img">`
|
||||
: `<span class="hdm-top-av-placeholder">${_esc(dog.name.charAt(0).toUpperCase())}</span>`;
|
||||
const vorname = dog.besitzer_name ? _esc(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">${_esc(dog.name)}</div>
|
||||
${dog.rasse ? `<div class="hdm-top-rasse">${_esc(dog.rasse)}</div>` : ''}
|
||||
${vorname ? `<div class="hdm-top-besitzer">von ${vorname}</div>` : ''}
|
||||
</div>
|
||||
<div class="hdm-top-stimmen">${dog.stimmen} ⭐</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('')
|
||||
: `<p style="color:var(--c-text-secondary);padding:var(--space-4)">Noch keine Stimmen diesen Monat. Sei der Erste!</p>`;
|
||||
|
||||
content.innerHTML = `
|
||||
<div class="hdm-header">
|
||||
<div class="hdm-trophy">🏆</div>
|
||||
<h2 class="hdm-title">Hund des Monats</h2>
|
||||
<div class="hdm-monat">${_esc(monthName)}</div>
|
||||
</div>
|
||||
|
||||
${voteSection}
|
||||
|
||||
<div class="hdm-section">
|
||||
<h3 class="hdm-section-title">Top 5 diesen Monat</h3>
|
||||
<div id="hdm-top-list">${topList}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Login-Link
|
||||
content.querySelector('#hdm-login-link')?.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
App.navigate('settings');
|
||||
});
|
||||
|
||||
// Vote-Buttons
|
||||
content.querySelectorAll('.hdm-vote-btn').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 });
|
||||
UI.toast.success('Stimme abgegeben!');
|
||||
// Refresh the tab
|
||||
await _renderHundDesMonats(content);
|
||||
} catch (err) {
|
||||
UI.toast.error(err.message || 'Fehler beim Abstimmen.');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// HELPER
|
||||
// ----------------------------------------------------------
|
||||
function _esc(str) {
|
||||
if (!str && str !== 0) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// PUBLIC
|
||||
// ----------------------------------------------------------
|
||||
return { init, refresh };
|
||||
|
||||
})();
|
||||
|
|
@ -18,12 +18,12 @@ window.Page_places = (() => {
|
|||
// Typen-Konfiguration
|
||||
// ----------------------------------------------------------
|
||||
const TYPEN = {
|
||||
restaurant: { icon: '🍽️', label: 'Restaurant & Café', color: '#F97316' },
|
||||
freilauf: { icon: '🐕', label: 'Freilauffläche', color: '#22C55E' },
|
||||
shop: { icon: '🛒', label: 'Shop', color: '#3B82F6' },
|
||||
kotbeutel: { icon: '🧻', label: 'Kotbeutel-Station', color: '#6B7280' },
|
||||
tierarzt: { icon: '🩺', label: 'Tierarzt', color: '#EF4444' },
|
||||
hundeschule: { icon: '🎓', label: 'Hundeschule', color: '#8B5CF6' },
|
||||
restaurant: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#fork-knife"></use></svg>', label: 'Restaurant & Café', color: '#F97316' },
|
||||
freilauf: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>', label: 'Freilauffläche', color: '#22C55E' },
|
||||
shop: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#shopping-cart"></use></svg>', label: 'Shop', color: '#3B82F6' },
|
||||
kotbeutel: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>', label: 'Kotbeutel-Station', color: '#6B7280' },
|
||||
tierarzt: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>', label: 'Tierarzt', color: '#EF4444' },
|
||||
hundeschule: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#graduation-cap"></use></svg>', label: 'Hundeschule', color: '#8B5CF6' },
|
||||
};
|
||||
|
||||
function _esc(s) {
|
||||
|
|
@ -54,13 +54,13 @@ window.Page_places = (() => {
|
|||
<!-- Toolbar -->
|
||||
<div class="places-toolbar">
|
||||
<div class="places-filter" id="places-filter">
|
||||
<button class="places-filter-btn active" data-typ="">🗺️ Alle</button>
|
||||
<button class="places-filter-btn active" data-typ=""><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-trifold"></use></svg> Alle</button>
|
||||
${Object.entries(TYPEN).map(([k, t]) =>
|
||||
`<button class="places-filter-btn" data-typ="${k}">${t.icon} ${t.label}</button>`
|
||||
).join('')}
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" id="places-add-btn" style="white-space:nowrap">
|
||||
+ Ort hinzufügen
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#plus"></use></svg> Ort hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -139,7 +139,7 @@ window.Page_places = (() => {
|
|||
L.Control.Locate = L.Control.extend({
|
||||
onAdd() {
|
||||
const btn = L.DomUtil.create('button', 'places-locate-btn');
|
||||
btn.innerHTML = '📍';
|
||||
btn.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>';
|
||||
btn.title = 'Meinen Standort';
|
||||
btn.onclick = async () => {
|
||||
try {
|
||||
|
|
@ -191,7 +191,7 @@ window.Page_places = (() => {
|
|||
_markers = [];
|
||||
|
||||
_filtered().forEach(place => {
|
||||
const t = TYPEN[place.typ] || { icon: '📍', color: '#6B7280' };
|
||||
const t = TYPEN[place.typ] || { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>', color: '#6B7280' };
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="
|
||||
|
|
@ -242,11 +242,11 @@ window.Page_places = (() => {
|
|||
}
|
||||
|
||||
function _cardHTML(p) {
|
||||
const t = TYPEN[p.typ] || { icon: '📍', label: p.typ, color: '#6B7280' };
|
||||
const t = TYPEN[p.typ] || { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>', label: p.typ, color: '#6B7280' };
|
||||
const flags = [
|
||||
p.hund_rein === true ? '🐕 Hund rein' : null,
|
||||
p.leine_pflicht === true ? '🔗 Leinenpflicht' : null,
|
||||
p.wasser_fuer_hunde === true ? '💧 Wasser' : null,
|
||||
p.hund_rein === true ? `${UI.icon('dog')} Hund rein` : null,
|
||||
p.leine_pflicht === true ? `${UI.icon('tag')} Leinenpflicht` : null,
|
||||
p.wasser_fuer_hunde === true ? `${UI.icon('drop')} Wasser` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
return `
|
||||
|
|
@ -260,7 +260,7 @@ window.Page_places = (() => {
|
|||
</div>
|
||||
${flags.length ? `<div class="places-card-flags">${flags.map(f => `<span class="places-flag">${f}</span>`).join('')}</div>` : ''}
|
||||
</div>
|
||||
<div class="places-card-arrow">›</div>
|
||||
<div class="places-card-arrow">${UI.icon('arrow-right')}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
|
@ -268,13 +268,13 @@ window.Page_places = (() => {
|
|||
// Detail-Modal
|
||||
// ----------------------------------------------------------
|
||||
function _openDetail(place) {
|
||||
const t = TYPEN[place.typ] || { icon: '📍', label: place.typ, color: '#6B7280' };
|
||||
const t = TYPEN[place.typ] || { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>', label: place.typ, color: '#6B7280' };
|
||||
const isOwn = _appState.user?.id === place.user_id;
|
||||
|
||||
const flags = [
|
||||
place.hund_rein === true ? '🐕 Hund erlaubt' : (place.hund_rein === false ? '🚫 Kein Hund' : null),
|
||||
place.leine_pflicht === true ? '🔗 Leinenpflicht' : (place.leine_pflicht === false ? '✅ Leine optional' : null),
|
||||
place.wasser_fuer_hunde === true ? '💧 Wasser vorhanden': null,
|
||||
place.hund_rein === true ? `${UI.icon('dog')} Hund erlaubt` : (place.hund_rein === false ? `${UI.icon('x')} Kein Hund` : null),
|
||||
place.leine_pflicht === true ? `${UI.icon('tag')} Leinenpflicht` : (place.leine_pflicht === false ? `${UI.icon('check')} Leine optional` : null),
|
||||
place.wasser_fuer_hunde === true ? `${UI.icon('drop')} Wasser vorhanden`: null,
|
||||
].filter(Boolean);
|
||||
|
||||
const body = `
|
||||
|
|
@ -285,8 +285,8 @@ window.Page_places = (() => {
|
|||
<div style="color:${t.color};font-size:0.9rem">${t.label}</div>
|
||||
</div>
|
||||
</div>
|
||||
${place.adresse ? `<p style="color:var(--c-text-secondary);margin-bottom:var(--space-2)">📍 ${_esc(place.adresse)}</p>` : ''}
|
||||
${place.website ? `<p style="margin-bottom:var(--space-2)"><a href="${_esc(place.website)}" target="_blank" style="color:var(--c-primary)">🌐 ${_esc(place.website)}</a></p>` : ''}
|
||||
${place.adresse ? `<p style="color:var(--c-text-secondary);margin-bottom:var(--space-2)">${UI.icon('map-pin')} ${_esc(place.adresse)}</p>` : ''}
|
||||
${place.website ? `<p style="margin-bottom:var(--space-2)"><a href="${_esc(place.website)}" target="_blank" style="color:var(--c-primary)">${UI.icon('arrow-square-out')} ${_esc(place.website)}</a></p>` : ''}
|
||||
${flags.length ? `<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin-top:var(--space-3)">${flags.map(f => `<span class="places-flag places-flag--detail">${f}</span>`).join('')}</div>` : ''}
|
||||
<p style="color:var(--c-text-muted);font-size:0.8rem;margin-top:var(--space-4)">
|
||||
Eingetragen von ${_esc(place.user_name || 'Unbekannt')}
|
||||
|
|
@ -301,7 +301,7 @@ window.Page_places = (() => {
|
|||
<button type="button" class="btn btn-primary flex-1" id="place-detail-close">Schließen</button>
|
||||
`;
|
||||
|
||||
UI.modal.open({ title: `${t.icon} ${place.name}`, body, footer });
|
||||
UI.modal.open({ title: `${t.icon} ${_esc(place.name)}`, body, footer });
|
||||
|
||||
document.getElementById('place-detail-close')?.addEventListener('click', UI.modal.close);
|
||||
|
||||
|
|
@ -341,7 +341,7 @@ window.Page_places = (() => {
|
|||
const isEdit = !!place;
|
||||
|
||||
const typOpts = Object.entries(TYPEN)
|
||||
.map(([k, t]) => `<option value="${k}" ${place?.typ === k ? 'selected' : ''}>${t.icon} ${t.label}</option>`)
|
||||
.map(([k, t]) => `<option value="${k}" ${place?.typ === k ? 'selected' : ''}>${t.label}</option>`)
|
||||
.join('');
|
||||
|
||||
const body = `
|
||||
|
|
@ -367,12 +367,12 @@ window.Page_places = (() => {
|
|||
<input class="form-control" type="text" id="pf-lon-disp"
|
||||
placeholder="Länge" readonly style="flex:1"
|
||||
value="${place ? place.lon.toFixed(6) : ''}">
|
||||
<button type="button" class="btn btn-secondary" id="pf-gps-btn" title="GPS">📍</button>
|
||||
<button type="button" class="btn btn-secondary" id="pf-gps-btn" title="GPS"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg></button>
|
||||
</div>
|
||||
<input type="hidden" name="lat" id="pf-lat" value="${place?.lat || ''}">
|
||||
<input type="hidden" name="lon" id="pf-lon" value="${place?.lon || ''}">
|
||||
<small id="pf-gps-hint" style="color:var(--c-text-secondary)">
|
||||
${place ? '✅ Position gespeichert' : 'GPS-Button drücken oder Standort ermitteln'}
|
||||
${place ? 'Position gespeichert' : 'GPS-Button drücken oder Standort ermitteln'}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
|
|
@ -392,15 +392,15 @@ window.Page_places = (() => {
|
|||
<label class="form-label">Hundefreundlichkeit</label>
|
||||
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
|
||||
<input type="checkbox" name="hund_rein" ${place?.hund_rein ? 'checked' : ''}>
|
||||
🐕 Hund darf rein
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg> Hund darf rein
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
|
||||
<input type="checkbox" name="leine_pflicht" ${place?.leine_pflicht ? 'checked' : ''}>
|
||||
🔗 Leinenpflicht beachten
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#tag"></use></svg> Leinenpflicht beachten
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
|
||||
<input type="checkbox" name="wasser_fuer_hunde" ${place?.wasser_fuer_hunde ? 'checked' : ''}>
|
||||
💧 Wasser für Hunde vorhanden
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#drop"></use></svg> Wasser für Hunde vorhanden
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
|
@ -414,7 +414,7 @@ window.Page_places = (() => {
|
|||
</button>
|
||||
`;
|
||||
|
||||
UI.modal.open({ title: isEdit ? `${place.name} bearbeiten` : '📍 Neuer Ort', body, footer });
|
||||
UI.modal.open({ title: isEdit ? `${_esc(place.name)} bearbeiten` : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg> Neuer Ort', body, footer });
|
||||
|
||||
document.getElementById('place-form-cancel')?.addEventListener('click', UI.modal.close);
|
||||
|
||||
|
|
@ -429,7 +429,7 @@ window.Page_places = (() => {
|
|||
document.getElementById('pf-lon').value = pos.lon;
|
||||
document.getElementById('pf-lat-disp').value = pos.lat.toFixed(6);
|
||||
document.getElementById('pf-lon-disp').value = pos.lon.toFixed(6);
|
||||
document.getElementById('pf-gps-hint').textContent = '✅ Standort ermittelt';
|
||||
document.getElementById('pf-gps-hint').textContent = 'Standort ermittelt';
|
||||
} catch {
|
||||
UI.toast.error('GPS nicht verfügbar.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,8 +54,8 @@ window.Page_poison = (() => {
|
|||
async function _render() {
|
||||
_container.innerHTML = `
|
||||
<div style="display:flex;gap:var(--space-2);margin-bottom:var(--space-3);flex-wrap:wrap">
|
||||
<button class="btn btn-secondary" id="poison-btn-locate">📍 Mein Standort</button>
|
||||
<button class="btn btn-danger" id="poison-btn-report">⚠️ Giftköder melden</button>
|
||||
<button class="btn btn-secondary" id="poison-btn-locate">${UI.icon('map-pin')} Mein Standort</button>
|
||||
<button class="btn btn-danger" id="poison-btn-report">${UI.icon('warning-octagon')} Giftköder melden</button>
|
||||
</div>
|
||||
|
||||
<div id="poison-map"
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ window.Page_routes = (() => {
|
|||
let _sortBy = 'newest';
|
||||
let _onlyMine = false;
|
||||
|
||||
// Ansichts-Modus: 'list' | 'map'
|
||||
let _viewMode = 'list';
|
||||
let _searchMap = null; // L.map Instanz der Suchkarte
|
||||
let _searchLines = new Map(); // routeId → { line, route }
|
||||
|
||||
// Mini-Karten auf den Route-Cards
|
||||
let _miniMaps = new Map(); // routeId → L.map
|
||||
let _leafletReady = false;
|
||||
|
|
@ -74,11 +79,15 @@ window.Page_routes = (() => {
|
|||
<div class="rk-search-row">
|
||||
<input class="rk-search" id="rk-search" type="search"
|
||||
placeholder="🔍 Route suchen…" autocomplete="off">
|
||||
<div class="rk-view-toggle">
|
||||
<button class="rk-view-btn${_viewMode==='list'?' active':''}" id="rk-view-list" title="Liste">${UI.icon('list')}</button>
|
||||
<button class="rk-view-btn${_viewMode==='map'?' active':''}" id="rk-view-map" title="Karte">${UI.icon('map-trifold')}</button>
|
||||
</div>
|
||||
<label class="btn btn-secondary btn-sm rk-imp-btn" title="GPX / KML / TCX importieren">
|
||||
📥 Import
|
||||
${UI.icon('download-simple')} Import
|
||||
<input type="file" id="rk-import-input" accept=".gpx,.kml,.tcx" style="display:none">
|
||||
</label>
|
||||
<button class="btn btn-primary btn-sm rk-rec-btn" id="rk-rec-btn">🔴 Aufzeichnen</button>
|
||||
<button class="btn btn-primary btn-sm rk-rec-btn" id="rk-rec-btn">${UI.icon('path')} Aufzeichnen</button>
|
||||
</div>
|
||||
<div class="rk-filters" id="rk-filters">
|
||||
<div class="rk-filter-group">
|
||||
|
|
@ -114,6 +123,8 @@ window.Page_routes = (() => {
|
|||
document.getElementById('rk-search').addEventListener('input', e => {
|
||||
_search = e.target.value.toLowerCase(); _applyFilter();
|
||||
});
|
||||
document.getElementById('rk-view-list').addEventListener('click', () => _switchView('list'));
|
||||
document.getElementById('rk-view-map').addEventListener('click', () => _switchView('map'));
|
||||
document.getElementById('rk-rec-btn').addEventListener('click', () => {
|
||||
App.navigate('map');
|
||||
setTimeout(() => window.Page_map?.startRecording?.(), 600);
|
||||
|
|
@ -138,6 +149,177 @@ window.Page_routes = (() => {
|
|||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// View-Toggle
|
||||
// ----------------------------------------------------------
|
||||
function _switchView(mode) {
|
||||
_viewMode = mode;
|
||||
document.getElementById('rk-view-list')?.classList.toggle('active', mode === 'list');
|
||||
document.getElementById('rk-view-map')?.classList.toggle('active', mode === 'map');
|
||||
|
||||
const layout = document.querySelector('.rk-layout');
|
||||
const grid = document.getElementById('rk-grid');
|
||||
|
||||
if (mode === 'map') {
|
||||
if (grid) grid.style.display = 'none';
|
||||
|
||||
// Alten Map-Container entfernen falls vorhanden
|
||||
document.getElementById('rk-map-section')?.remove();
|
||||
if (_searchMap) { _searchMap.remove(); _searchMap = null; _searchLines.clear(); }
|
||||
|
||||
// Als fixed Overlay direkt in <body> — kein Konflikt mit .rk-layout overflow:hidden
|
||||
const mapH = window.innerHeight - 160;
|
||||
const sec = document.createElement('div');
|
||||
sec.id = 'rk-map-section';
|
||||
sec.className = 'rk-map-section';
|
||||
sec.innerHTML = `
|
||||
<div class="rk-map-bar">
|
||||
<input class="rk-map-loc-input" id="rk-map-loc" type="search"
|
||||
placeholder="🔍 Ort suchen…" autocomplete="off">
|
||||
<button class="btn btn-secondary btn-sm" id="rk-map-gps" title="Mein Standort">📍</button>
|
||||
</div>
|
||||
<div id="rk-search-map" style="height:${mapH}px;width:100%"></div>
|
||||
<div id="rk-map-hint" class="rk-map-hint">Route antippen um Details zu sehen</div>
|
||||
`;
|
||||
document.body.appendChild(sec);
|
||||
|
||||
// Wie _initMiniMaps: pollen bis window.L bereit ist
|
||||
_pollAndInitSearchMap();
|
||||
|
||||
} else {
|
||||
document.getElementById('rk-map-section')?.remove();
|
||||
if (_searchMap) { _searchMap.remove(); _searchMap = null; _searchLines.clear(); }
|
||||
if (grid) grid.style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Suchkarte
|
||||
// ----------------------------------------------------------
|
||||
function _pollAndInitSearchMap() {
|
||||
if (window.L) { _initSearchMap(); return; }
|
||||
let tries = 0;
|
||||
const poll = setInterval(() => {
|
||||
if (window.L || ++tries > 40) {
|
||||
clearInterval(poll);
|
||||
if (window.L) _initSearchMap();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function _initSearchMap() {
|
||||
if (!document.getElementById('rk-search-map')) return;
|
||||
|
||||
const center = _userPos ? [_userPos.lat, _userPos.lon] : [51.1, 10.4];
|
||||
const zoom = _userPos ? 13 : 6;
|
||||
|
||||
_searchMap = L.map('rk-search-map', { zoomControl: true, attributionControl: false })
|
||||
.setView(center, zoom);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_searchMap);
|
||||
setTimeout(() => _searchMap?.invalidateSize(), 100);
|
||||
setTimeout(() => _searchMap?.invalidateSize(), 600);
|
||||
_renderRoutesOnMap();
|
||||
|
||||
// Standort-Button
|
||||
document.getElementById('rk-map-gps')?.addEventListener('click', async () => {
|
||||
try {
|
||||
const pos = await API.getLocation();
|
||||
_userPos = pos;
|
||||
_searchMap.setView([pos.lat, pos.lon], 14);
|
||||
} catch { UI.toast.warning('Standort nicht verfügbar.'); }
|
||||
});
|
||||
|
||||
// Geocoding-Suche
|
||||
const locInput = document.getElementById('rk-map-loc');
|
||||
let _geoDebounce;
|
||||
locInput?.addEventListener('keydown', e => {
|
||||
if (e.key !== 'Enter') return;
|
||||
clearTimeout(_geoDebounce);
|
||||
_geocodeAndFly(locInput.value.trim());
|
||||
});
|
||||
locInput?.addEventListener('input', () => {
|
||||
clearTimeout(_geoDebounce);
|
||||
const q = locInput.value.trim();
|
||||
if (q.length < 3) return;
|
||||
_geoDebounce = setTimeout(() => _geocodeAndFly(q), 800);
|
||||
});
|
||||
}
|
||||
|
||||
async function _geocodeAndFly(query) {
|
||||
if (!query || !_searchMap) return;
|
||||
try {
|
||||
const r = await fetch(
|
||||
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&limit=1&accept-language=de`,
|
||||
{ cache: 'no-store' }
|
||||
);
|
||||
const data = await r.json();
|
||||
if (!data.length) { UI.toast.info('Ort nicht gefunden.'); return; }
|
||||
const { lat, lon, boundingbox } = data[0];
|
||||
if (boundingbox) {
|
||||
_searchMap.fitBounds([[+boundingbox[0], +boundingbox[2]], [+boundingbox[1], +boundingbox[3]]],
|
||||
{ maxZoom: 14 });
|
||||
} else {
|
||||
_searchMap.setView([+lat, +lon], 13);
|
||||
}
|
||||
} catch { UI.toast.warning('Suche fehlgeschlagen.'); }
|
||||
}
|
||||
|
||||
function _renderRoutesOnMap() {
|
||||
if (!_searchMap || !window.L) return;
|
||||
|
||||
// Alte Linien entfernen
|
||||
_searchLines.forEach(({ line }) => line.remove());
|
||||
_searchLines.clear();
|
||||
|
||||
const hint = document.getElementById('rk-map-hint');
|
||||
|
||||
_data.forEach(route => {
|
||||
const pts = (route.preview_track || []).map(p => [p.lat, p.lon]);
|
||||
if (pts.length < 2) return;
|
||||
|
||||
const line = L.polyline(pts, {
|
||||
color: '#C4843A', weight: 4, opacity: 0.75,
|
||||
}).addTo(_searchMap);
|
||||
|
||||
// Start-/End-Marker
|
||||
const startM = L.circleMarker(pts[0], {
|
||||
radius: 6, color: '#22C55E', fillColor: '#22C55E', fillOpacity: 1, weight: 1.5
|
||||
}).addTo(_searchMap);
|
||||
const endM = L.circleMarker(pts[pts.length - 1], {
|
||||
radius: 6, color: '#EF4444', fillColor: '#EF4444', fillOpacity: 1, weight: 1.5
|
||||
}).addTo(_searchMap);
|
||||
|
||||
// Tooltip mit Namen und Distanz
|
||||
const tip = `<b>${_esc(route.name)}</b>${route.distanz_km ? ` · ${route.distanz_km.toFixed(1)} km` : ''}`;
|
||||
line.bindTooltip(tip, { sticky: true, className: 'rk-map-tooltip' });
|
||||
|
||||
// Hover-Highlight
|
||||
line.on('mouseover', () => line.setStyle({ color: '#e67e22', weight: 6, opacity: 1 }));
|
||||
line.on('mouseout', () => line.setStyle({ color: '#C4843A', weight: 4, opacity: 0.75 }));
|
||||
|
||||
// Klick → Detail-Modal (Karte bleibt im Hintergrund erhalten)
|
||||
const onClick = () => {
|
||||
if (hint) hint.textContent = `Lädt „${route.name}"…`;
|
||||
_openDetail(route.id).finally(() => {
|
||||
if (hint) hint.textContent = 'Route antippen um Details zu sehen';
|
||||
});
|
||||
};
|
||||
line.on('click', onClick);
|
||||
startM.on('click', onClick);
|
||||
|
||||
_searchLines.set(route.id, { line, startM, endM });
|
||||
});
|
||||
|
||||
// Wenn Routen vorhanden: Karte auf alle Routes zoomen (nur beim ersten Mal)
|
||||
if (_data.length && _searchLines.size && !_userPos) {
|
||||
const allPts = [..._searchLines.values()].flatMap(({ line }) => line.getLatLngs());
|
||||
if (allPts.length) {
|
||||
try { _searchMap.fitBounds(L.latLngBounds(allPts), { padding: [20, 20], maxZoom: 14 }); }
|
||||
catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Daten
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -178,6 +360,7 @@ window.Page_routes = (() => {
|
|||
|
||||
_filtered = list;
|
||||
_renderGrid();
|
||||
if (_viewMode === 'map' && _searchMap) _renderRoutesOnMap();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -511,12 +694,27 @@ window.Page_routes = (() => {
|
|||
// ----------------------------------------------------------
|
||||
// Nearby POIs
|
||||
// ----------------------------------------------------------
|
||||
|
||||
// Gibt true zurück wenn poi.lat/lon innerhalb maxMeters eines Track-Punkts liegt
|
||||
function _isNearTrack(poi, track, maxMeters) {
|
||||
const R = 6371000;
|
||||
const plat = poi.lat * Math.PI / 180;
|
||||
const plon = poi.lon * Math.PI / 180;
|
||||
for (const pt of track) {
|
||||
const dlat = plat - pt.lat * Math.PI / 180;
|
||||
const dlon = plon - pt.lon * Math.PI / 180;
|
||||
const a = dlat*dlat + Math.cos(plat) * Math.cos(pt.lat * Math.PI/180) * dlon*dlon;
|
||||
if (R * Math.sqrt(a) <= maxMeters) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function _loadNearbyPois(track) {
|
||||
const lats = track.map(p => p.lat), lons = track.map(p => p.lon);
|
||||
const south = Math.min(...lats), north = Math.max(...lats);
|
||||
const west = Math.min(...lons), east = Math.max(...lons);
|
||||
// Etwas aufweiten (ca. 300m)
|
||||
const pad = 0.003;
|
||||
// Bbox-Padding zum Abrufen (ca. 150m) — echte Distanzfilterung danach
|
||||
const pad = 0.0015;
|
||||
const bbox = { south: south-pad, north: north+pad, west: west-pad, east: east+pad };
|
||||
|
||||
const results = [];
|
||||
|
|
@ -524,7 +722,9 @@ window.Page_routes = (() => {
|
|||
try {
|
||||
const params = new URLSearchParams({ type, fast: 'true', ...bbox });
|
||||
const pois = await fetch(`/api/osm/pois?${params}`).then(r => r.json());
|
||||
pois.forEach(p => results.push({ ...p, _icon: icon, _label: label }));
|
||||
pois
|
||||
.filter(p => _isNearTrack(p, track, 100)) // max 100m vom Track-Verlauf
|
||||
.forEach(p => results.push({ ...p, _icon: icon, _label: label }));
|
||||
} catch {}
|
||||
}));
|
||||
return results;
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ window.Page_settings = (() => {
|
|||
<div style="color:var(--c-text-secondary);font-size:var(--text-sm)">${_esc(u.email)}</div>
|
||||
${u.is_premium
|
||||
? `<span class="badge badge-primary" style="margin-top:var(--space-1)">
|
||||
⭐ Ban Yaro Plus
|
||||
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#star"></use></svg> Ban Yaro Plus
|
||||
</span>`
|
||||
: `<span class="badge" style="margin-top:var(--space-1);
|
||||
color:var(--c-text-secondary)">
|
||||
|
|
@ -68,25 +68,52 @@ window.Page_settings = (() => {
|
|||
<div class="card-body" style="padding:0">
|
||||
<div class="sidebar-item" data-page="dog-profile"
|
||||
style="padding:var(--space-4);border-radius:0;border-bottom:1px solid var(--c-border)">
|
||||
<span>🐕</span>
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>
|
||||
<span>Hunde-Profile</span>
|
||||
<span style="margin-left:auto;color:var(--c-text-secondary)">›</span>
|
||||
</div>
|
||||
<div class="sidebar-item" id="settings-push-btn"
|
||||
style="padding:var(--space-4);border-radius:0;border-bottom:1px solid var(--c-border)">
|
||||
<span>🔔</span>
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bell"></use></svg>
|
||||
<span>Push-Benachrichtigungen</span>
|
||||
<span style="margin-left:auto;color:var(--c-text-secondary)">›</span>
|
||||
</div>
|
||||
<div class="sidebar-item" id="settings-logout-btn"
|
||||
style="padding:var(--space-4);border-radius:0;cursor:pointer;
|
||||
color:var(--c-danger)">
|
||||
<span>🚪</span>
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#sign-out"></use></svg>
|
||||
<span>Abmelden</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom:var(--space-4)">
|
||||
<div style="padding:var(--space-3) var(--space-4);
|
||||
font-size:var(--text-xs);font-weight:600;
|
||||
color:var(--c-text-secondary);text-transform:uppercase;
|
||||
letter-spacing:0.05em;border-bottom:1px solid var(--c-border)">
|
||||
App-Einstellungen
|
||||
</div>
|
||||
<div class="card-body" style="padding:0">
|
||||
<div style="display:flex;align-items:center;gap:var(--space-3);
|
||||
padding:var(--space-4);border-bottom:1px solid var(--c-border)">
|
||||
<svg class="ph-icon" aria-hidden="true" style="width:1.25rem;height:1.25rem"><use href="/icons/phosphor.svg#eye-slash"></use></svg>
|
||||
<div style="flex:1">
|
||||
<div style="font-weight:500">Pocket-Modus beim Aufzeichnen</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
|
||||
Schwarzes Overlay hält den Bildschirm aktiv (GPS läuft) — ideal für die Hosentasche.
|
||||
Helligkeit auf Minimum reduzieren für optimalen Akku-Schutz.
|
||||
</div>
|
||||
</div>
|
||||
<label class="toggle" style="flex-shrink:0">
|
||||
<input type="checkbox" id="toggle-pocket-mode"
|
||||
${localStorage.getItem('by_pocket_mode') === 'true' ? 'checked' : ''}>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align:center;color:var(--c-text-secondary);
|
||||
font-size:var(--text-xs)">
|
||||
Ban Yaro · banyaro.app<br>
|
||||
|
|
@ -121,6 +148,13 @@ window.Page_settings = (() => {
|
|||
UI.toast.warning('Push-Benachrichtigungen konnten nicht aktiviert werden.');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('toggle-pocket-mode')?.addEventListener('change', e => {
|
||||
localStorage.setItem('by_pocket_mode', String(e.target.checked));
|
||||
UI.toast.info(e.target.checked
|
||||
? 'Pocket-Modus aktiviert — Bildschirm bleibt bei Aufzeichnung an.'
|
||||
: 'Pocket-Modus deaktiviert.');
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -9,10 +9,10 @@ window.Page_sitting = (() => {
|
|||
// Konstanten
|
||||
// ----------------------------------------------------------
|
||||
const SERVICES = [
|
||||
{ id: 'tagesbetreuung', label: 'Tagesbetreuung', icon: '☀️' },
|
||||
{ id: 'uebernachtung', label: 'Übernachtung', icon: '🌙' },
|
||||
{ id: 'gassi', label: 'Gassi gehen', icon: '🦮' },
|
||||
{ id: 'hausbesuch', label: 'Hausbesuch', icon: '🏠' },
|
||||
{ id: 'tagesbetreuung', label: 'Tagesbetreuung', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#sun"></use></svg>' },
|
||||
{ id: 'uebernachtung', label: 'Übernachtung', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#moon"></use></svg>' },
|
||||
{ id: 'gassi', label: 'Gassi gehen', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>' },
|
||||
{ id: 'hausbesuch', label: 'Hausbesuch', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#house-line"></use></svg>' },
|
||||
];
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -44,10 +44,10 @@ window.Page_sitting = (() => {
|
|||
function _render() {
|
||||
_container.innerHTML = `
|
||||
<div class="sitting-tabs" id="sit-tabs">
|
||||
<button class="sitting-tab active" data-sit-tab="suchen">🔍 Sitter finden</button>
|
||||
<button class="sitting-tab active" data-sit-tab="suchen">${UI.icon('magnifying-glass')} Sitter finden</button>
|
||||
${_state.user ? `
|
||||
<button class="sitting-tab" data-sit-tab="profil">👤 Mein Profil</button>
|
||||
<button class="sitting-tab" data-sit-tab="anfragen">📬 Anfragen</button>
|
||||
<button class="sitting-tab" data-sit-tab="profil">${UI.icon('user')} Mein Profil</button>
|
||||
<button class="sitting-tab" data-sit-tab="anfragen">${UI.icon('bell')} Anfragen</button>
|
||||
` : ''}
|
||||
</div>
|
||||
<div id="sit-content" class="sitting-content"></div>
|
||||
|
|
@ -95,7 +95,7 @@ window.Page_sitting = (() => {
|
|||
// ---- Tab: Sitter suchen ----
|
||||
function _renderSuchen(el) {
|
||||
if (!_sitters.length) {
|
||||
el.innerHTML = UI.emptyState({ icon: '🐕', title: 'Keine Sitter', text: 'Noch keine Sitter in deiner Nähe registriert.' });
|
||||
el.innerHTML = UI.emptyState({ icon: 'dog', title: 'Keine Sitter', text: 'Noch keine Sitter in deiner Nähe registriert.' });
|
||||
return;
|
||||
}
|
||||
el.innerHTML = `
|
||||
|
|
@ -115,10 +115,10 @@ window.Page_sitting = (() => {
|
|||
: '';
|
||||
return `
|
||||
<div class="sitting-card" data-sit-id="${s.id}">
|
||||
<div class="sitting-card-avatar">🐾</div>
|
||||
<div class="sitting-card-avatar">${UI.icon('paw-print')}</div>
|
||||
<div class="sitting-card-body">
|
||||
<div class="sitting-card-name">${UI.escHtml(s.sitter_name)}</div>
|
||||
${dist ? `<div class="sitting-card-dist">📍 ${dist} entfernt</div>` : ''}
|
||||
${dist ? `<div class="sitting-card-dist">${UI.icon('map-pin')} ${dist} entfernt</div>` : ''}
|
||||
${s.beschreibung ? `<div class="sitting-card-desc">${UI.escHtml(s.beschreibung)}</div>` : ''}
|
||||
<div class="sitting-services">${svcs}</div>
|
||||
</div>
|
||||
|
|
@ -135,7 +135,7 @@ window.Page_sitting = (() => {
|
|||
if (!_mySitter) {
|
||||
el.innerHTML = `
|
||||
<div class="sitting-empty-profil">
|
||||
<div style="font-size:3rem">🐾</div>
|
||||
<div style="font-size:3rem">${UI.icon('paw-print')}</div>
|
||||
<h3>Werde Hundesitter</h3>
|
||||
<p>Biete anderen Hundebesitzern deine Dienste an und verdiene etwas dazu.</p>
|
||||
<button class="btn btn-primary" id="sit-create-profil-btn">Profil erstellen</button>
|
||||
|
|
@ -149,9 +149,9 @@ window.Page_sitting = (() => {
|
|||
<div class="sitting-my-profil">
|
||||
<div class="sitting-profil-header">
|
||||
<div class="sitting-profil-status ${s.aktiv ? 'active' : 'inactive'}">
|
||||
${s.aktiv ? '✅ Aktiv' : '⏸️ Pausiert'}
|
||||
${s.aktiv ? `${UI.icon('check')} Aktiv` : 'Pausiert'}
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-sm" id="sit-edit-profil-btn">✏️ Bearbeiten</button>
|
||||
<button class="btn btn-secondary btn-sm" id="sit-edit-profil-btn">${UI.icon('pencil-simple')} Bearbeiten</button>
|
||||
</div>
|
||||
${s.beschreibung ? `<p>${UI.escHtml(s.beschreibung)}</p>` : ''}
|
||||
<div class="sitting-profil-facts">
|
||||
|
|
@ -172,17 +172,17 @@ window.Page_sitting = (() => {
|
|||
let html = '';
|
||||
|
||||
if (inbox.length) {
|
||||
html += `<div class="sitting-section-label">📬 Eingehende Anfragen (als Sitter)</div>`;
|
||||
html += `<div class="sitting-section-label">${UI.icon('bell')} Eingehende Anfragen (als Sitter)</div>`;
|
||||
html += inbox.map(r => _requestCardHTML(r, 'inbox')).join('');
|
||||
}
|
||||
|
||||
if (myReqs.length) {
|
||||
html += `<div class="sitting-section-label" style="margin-top:var(--space-4)">📤 Meine Anfragen</div>`;
|
||||
html += `<div class="sitting-section-label" style="margin-top:var(--space-4)">${UI.icon('upload')} Meine Anfragen</div>`;
|
||||
html += myReqs.map(r => _requestCardHTML(r, 'sent')).join('');
|
||||
}
|
||||
|
||||
if (!inbox.length && !myReqs.length) {
|
||||
html = UI.emptyState({ icon: '📬', title: 'Keine Anfragen', text: 'Noch keine Sitting-Anfragen vorhanden.' });
|
||||
html = UI.emptyState({ icon: 'bell', title: 'Keine Anfragen', text: 'Noch keine Sitting-Anfragen vorhanden.' });
|
||||
}
|
||||
|
||||
el.innerHTML = html;
|
||||
|
|
@ -198,7 +198,7 @@ window.Page_sitting = (() => {
|
|||
<span class="sitting-req-name">${UI.escHtml(name || '?')}</span>
|
||||
<span class="sitting-req-status" style="color:${color}">${r.status}</span>
|
||||
</div>
|
||||
<div class="sitting-req-dates">📅 ${r.von} – ${r.bis}</div>
|
||||
<div class="sitting-req-dates">${UI.icon('calendar-dots')} ${r.von} – ${r.bis}</div>
|
||||
${r.nachricht ? `<div class="sitting-req-msg">${UI.escHtml(r.nachricht)}</div>` : ''}
|
||||
${r.status === 'offen' ? _requestActions(r.id, mode) : ''}
|
||||
</div>
|
||||
|
|
@ -209,14 +209,14 @@ window.Page_sitting = (() => {
|
|||
if (mode === 'inbox') {
|
||||
return `
|
||||
<div class="sitting-req-actions">
|
||||
<button class="btn btn-primary btn-sm" data-sit-accept="${id}">✅ Annehmen</button>
|
||||
<button class="btn btn-danger btn-sm" data-sit-decline="${id}">❌ Ablehnen</button>
|
||||
<button class="btn btn-primary btn-sm" data-sit-accept="${id}">${UI.icon('check')} Annehmen</button>
|
||||
<button class="btn btn-danger btn-sm" data-sit-decline="${id}">${UI.icon('x')} Ablehnen</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return `
|
||||
<div class="sitting-req-actions">
|
||||
<button class="btn btn-secondary btn-sm" data-sit-cancel="${id}">🚫 Abbrechen</button>
|
||||
<button class="btn btn-secondary btn-sm" data-sit-cancel="${id}">${UI.icon('x')} Abbrechen</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
|
@ -236,9 +236,9 @@ window.Page_sitting = (() => {
|
|||
: null;
|
||||
|
||||
const body = `
|
||||
<div class="sitting-detail-avatar">🐾</div>
|
||||
<div class="sitting-detail-avatar">${UI.icon('paw-print')}</div>
|
||||
<h3 style="margin:var(--space-2) 0">${UI.escHtml(s.sitter_name)}</h3>
|
||||
${dist ? `<div style="color:var(--c-text-secondary);margin-bottom:var(--space-2)">📍 ${dist} entfernt</div>` : ''}
|
||||
${dist ? `<div style="color:var(--c-text-secondary);margin-bottom:var(--space-2)">${UI.icon('map-pin')} ${dist} entfernt</div>` : ''}
|
||||
${s.beschreibung ? `<p>${UI.escHtml(s.beschreibung)}</p>` : ''}
|
||||
<div class="sitting-services" style="margin:var(--space-3) 0">${svcs}</div>
|
||||
<div class="sitting-profil-facts">
|
||||
|
|
@ -249,7 +249,7 @@ window.Page_sitting = (() => {
|
|||
`;
|
||||
|
||||
const footer = _state.user && _mySitter?.user_id !== s.user_id ? `
|
||||
<button class="btn btn-primary" id="sit-anfrage-btn">📬 Anfrage senden</button>
|
||||
<button class="btn btn-primary" id="sit-anfrage-btn">${UI.icon('bell')} Anfrage senden</button>
|
||||
` : (!_state.user ? `<span style="color:var(--c-text-secondary)">Zum Anfragen bitte einloggen.</span>` : '');
|
||||
|
||||
UI.modal.open({ title: 'Sitter-Profil', body, footer });
|
||||
|
|
@ -368,7 +368,7 @@ window.Page_sitting = (() => {
|
|||
<input class="form-control" type="number" step="any" name="lon" id="sit-lon" value="${s?.lon || ''}">
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary btn-sm" id="sit-gps-btn">📍 Meine Position</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" id="sit-gps-btn">${UI.icon('map-pin')} Meine Position</button>
|
||||
<div class="form-group" style="margin-top:var(--space-3)">
|
||||
<label class="form-label">Umkreis (km)</label>
|
||||
<input class="form-control" type="number" min="1" max="100" name="radius_km" value="${s?.radius_km ?? 20}">
|
||||
|
|
|
|||
|
|
@ -65,10 +65,10 @@ window.Page_walks = (() => {
|
|||
<!-- Toolbar -->
|
||||
<div class="walks-toolbar">
|
||||
<div class="walks-view-toggle" id="walks-view-toggle">
|
||||
<button class="walks-view-btn active" data-view="liste">📋 Liste</button>
|
||||
<button class="walks-view-btn" data-view="karte">🗺️ Karte</button>
|
||||
<button class="walks-view-btn active" data-view="liste">${UI.icon('list')} Liste</button>
|
||||
<button class="walks-view-btn" data-view="karte">${UI.icon('map-trifold')} Karte</button>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" id="walks-create-btn">+ Treffen planen</button>
|
||||
<button class="btn btn-primary btn-sm" id="walks-create-btn">${UI.icon('plus')} Treffen planen</button>
|
||||
</div>
|
||||
|
||||
<!-- Liste -->
|
||||
|
|
@ -143,7 +143,7 @@ window.Page_walks = (() => {
|
|||
if (!_data.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)">🐕</div>
|
||||
<div style="font-size:3rem;margin-bottom:var(--space-3)">${UI.icon('dog')}</div>
|
||||
<p style="color:var(--c-text-secondary)">Noch keine Treffen in deiner Nähe.</p>
|
||||
<button class="btn btn-primary" style="margin-top:var(--space-4)" id="walks-first-btn">
|
||||
Erstes Treffen planen
|
||||
|
|
@ -160,12 +160,12 @@ window.Page_walks = (() => {
|
|||
let html = '';
|
||||
|
||||
if (heute.length) {
|
||||
html += `<div class="walks-section-label">🌟 Heute</div>`;
|
||||
html += `<div class="walks-section-label">${UI.icon('star')} Heute</div>`;
|
||||
html += heute.map(w => _walkCardHTML(w)).join('');
|
||||
}
|
||||
|
||||
if (upcoming.length) {
|
||||
html += `<div class="walks-section-label">📅 Demnächst</div>`;
|
||||
html += `<div class="walks-section-label">${UI.icon('calendar-dots')} Demnächst</div>`;
|
||||
html += upcoming.map(w => _walkCardHTML(w)).join('');
|
||||
}
|
||||
|
||||
|
|
@ -190,12 +190,12 @@ window.Page_walks = (() => {
|
|||
</div>
|
||||
<div class="walks-card-body">
|
||||
<div class="walks-card-title">${_esc(w.titel)}</div>
|
||||
${w.ort_name ? `<div class="walks-card-ort">📍 ${_esc(w.ort_name)}</div>` : ''}
|
||||
${w.ort_name ? `<div class="walks-card-ort">${UI.icon('map-pin')} ${_esc(w.ort_name)}</div>` : ''}
|
||||
<div class="walks-card-meta">
|
||||
<span class="walks-badge ${isFull ? 'walks-badge--full' : 'walks-badge--open'}">
|
||||
${isFull ? '🔴 Voll' : `🟢 ${spots} Platz${spots !== 1 ? 'e' : ''} frei`}
|
||||
</span>
|
||||
<span class="walks-badge">🐾 ${w.teilnehmer_count}/${w.max_teilnehmer}</span>
|
||||
<span class="walks-badge">${UI.icon('paw-print')} ${w.teilnehmer_count}/${w.max_teilnehmer}</span>
|
||||
${isOwn ? '<span class="walks-badge walks-badge--own">Mein Treffen</span>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -241,7 +241,7 @@ window.Page_walks = (() => {
|
|||
html: `<div style="background:${color};color:#fff;font-size:14px;font-weight:700;
|
||||
width:32px;height:32px;border-radius:50%;display:flex;align-items:center;
|
||||
justify-content:center;box-shadow:0 2px 5px rgba(0,0,0,0.3);
|
||||
border:2px solid rgba(255,255,255,0.8)">🐕</div>`,
|
||||
border:2px solid rgba(255,255,255,0.8)">${UI.icon('dog')}</div>`,
|
||||
iconSize: [32, 32], iconAnchor: [16, 16],
|
||||
});
|
||||
const m = L.marker([w.lat, w.lon], { icon })
|
||||
|
|
@ -273,8 +273,8 @@ window.Page_walks = (() => {
|
|||
const teilnehmerHTML = walk.teilnehmer?.length
|
||||
? walk.teilnehmer.map(t => `
|
||||
<div class="walks-participant">
|
||||
<span class="walks-participant-name">🧑 ${_esc(t.user_name)}</span>
|
||||
${t.hunde ? `<span class="walks-participant-hunde">🐕 ${_esc(t.hunde)}</span>` : ''}
|
||||
<span class="walks-participant-name">${UI.icon('user')} ${_esc(t.user_name)}</span>
|
||||
${t.hunde ? `<span class="walks-participant-hunde">${UI.icon('dog')} ${_esc(t.hunde)}</span>` : ''}
|
||||
</div>`).join('')
|
||||
: `<p style="color:var(--c-text-muted)">Noch keine Teilnehmer.</p>`;
|
||||
|
||||
|
|
@ -284,12 +284,12 @@ window.Page_walks = (() => {
|
|||
${_fmtDate(walk.datum)}<br>
|
||||
<strong>um ${walk.uhrzeit} Uhr</strong>
|
||||
</div>
|
||||
${walk.ort_name ? `<div style="margin-top:var(--space-2);color:var(--c-text-secondary)">📍 ${_esc(walk.ort_name)}</div>` : ''}
|
||||
${walk.ort_name ? `<div style="margin-top:var(--space-2);color:var(--c-text-secondary)">${UI.icon('map-pin')} ${_esc(walk.ort_name)}</div>` : ''}
|
||||
<div style="margin-top:var(--space-2);display:flex;gap:var(--space-2);flex-wrap:wrap">
|
||||
<span class="walks-badge ${isFull ? 'walks-badge--full' : 'walks-badge--open'}">
|
||||
${isFull ? '🔴 Voll' : `🟢 ${spots} Platz${spots !== 1 ? 'e' : ''} frei`}
|
||||
</span>
|
||||
<span class="walks-badge">🐾 ${walk.teilnehmer_count}/${walk.max_teilnehmer} Teilnehmer</span>
|
||||
<span class="walks-badge">${UI.icon('paw-print')} ${walk.teilnehmer_count}/${walk.max_teilnehmer} Teilnehmer</span>
|
||||
${isOwn ? '<span class="walks-badge walks-badge--own">Dein Treffen</span>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -330,11 +330,11 @@ window.Page_walks = (() => {
|
|||
} else {
|
||||
footer = `
|
||||
<button type="button" class="btn btn-secondary flex-1" id="wd-close">Schließen</button>
|
||||
<button type="button" class="btn btn-primary flex-1" id="wd-join">🐕 Mitmachen</button>
|
||||
<button type="button" class="btn btn-primary flex-1" id="wd-join">${UI.icon('dog')} Mitmachen</button>
|
||||
`;
|
||||
}
|
||||
|
||||
UI.modal.open({ title: `🐕 ${walk.titel}`, body, footer });
|
||||
UI.modal.open({ title: `${UI.icon('dog')} ${walk.titel}`, body, footer });
|
||||
|
||||
document.getElementById('wd-close')?.addEventListener('click', UI.modal.close);
|
||||
|
||||
|
|
@ -398,14 +398,14 @@ window.Page_walks = (() => {
|
|||
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer;
|
||||
padding:var(--space-2) 0">
|
||||
<input type="checkbox" name="dog" value="${d.id}" checked>
|
||||
🐕 ${_esc(d.name)}
|
||||
${UI.icon('dog')} ${_esc(d.name)}
|
||||
</label>`).join('')
|
||||
: `<p style="color:var(--c-text-muted)">Keine Hunde im Profil — du kannst trotzdem mitmachen.</p>`;
|
||||
|
||||
const body = `
|
||||
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-4)">
|
||||
${_fmtDate(walk.datum)} um ${walk.uhrzeit} Uhr<br>
|
||||
${walk.ort_name ? `📍 ${_esc(walk.ort_name)}` : ''}
|
||||
${walk.ort_name ? `${UI.icon('map-pin')} ${_esc(walk.ort_name)}` : ''}
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Mit welchen Hunden?</label>
|
||||
|
|
@ -415,7 +415,7 @@ window.Page_walks = (() => {
|
|||
|
||||
const footer = `
|
||||
<button type="button" class="btn btn-secondary flex-1" id="join-cancel">Abbrechen</button>
|
||||
<button type="button" class="btn btn-primary flex-1" id="join-confirm">🐕 Mitmachen</button>
|
||||
<button type="button" class="btn btn-primary flex-1" id="join-confirm">${UI.icon('dog')} Mitmachen</button>
|
||||
`;
|
||||
|
||||
UI.modal.open({ title: `Treffen beitreten`, body, footer });
|
||||
|
|
@ -485,12 +485,12 @@ window.Page_walks = (() => {
|
|||
value="${_esc(v.ort_name || '')}"
|
||||
placeholder="z. B. Parkeingang Nordseite, U-Bahn Volkspark"
|
||||
style="flex:1">
|
||||
<button type="button" class="btn btn-secondary" id="walk-gps-btn" title="GPS">📍</button>
|
||||
<button type="button" class="btn btn-secondary" id="walk-gps-btn" title="GPS">${UI.icon('map-pin')}</button>
|
||||
</div>
|
||||
<input type="hidden" name="lat" id="walk-lat" value="${v.lat || ''}">
|
||||
<input type="hidden" name="lon" id="walk-lon" value="${v.lon || ''}">
|
||||
<small id="walk-gps-hint" style="color:var(--c-text-secondary)">
|
||||
${v.lat ? '✅ Position gespeichert' : 'GPS-Button für aktuellen Standort'}
|
||||
${v.lat ? `${UI.icon('check')} Position gespeichert` : 'GPS-Button für aktuellen Standort'}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
|
|
@ -512,11 +512,11 @@ window.Page_walks = (() => {
|
|||
const footer = `
|
||||
<button type="button" class="btn btn-secondary flex-1" id="wf-cancel">Abbrechen</button>
|
||||
<button type="submit" form="walk-form" class="btn btn-primary flex-1">
|
||||
${isEdit ? 'Speichern' : '📅 Treffen planen'}
|
||||
${isEdit ? 'Speichern' : `${UI.icon('calendar-dots')} Treffen planen`}
|
||||
</button>
|
||||
`;
|
||||
|
||||
UI.modal.open({ title: isEdit ? 'Treffen bearbeiten' : '🐕 Treffen planen', body, footer });
|
||||
UI.modal.open({ title: isEdit ? 'Treffen bearbeiten' : `${UI.icon('dog')} Treffen planen`, body, footer });
|
||||
|
||||
document.getElementById('wf-cancel')?.addEventListener('click', UI.modal.close);
|
||||
|
||||
|
|
@ -528,7 +528,7 @@ window.Page_walks = (() => {
|
|||
_userPos = pos;
|
||||
document.getElementById('walk-lat').value = pos.lat;
|
||||
document.getElementById('walk-lon').value = pos.lon;
|
||||
document.getElementById('walk-gps-hint').textContent = '✅ Standort ermittelt';
|
||||
document.getElementById('walk-gps-hint').innerHTML = `${UI.icon('check')} Standort ermittelt`;
|
||||
} catch { UI.toast.error('GPS nicht verfügbar.'); }
|
||||
UI.setLoading(btn, false);
|
||||
});
|
||||
|
|
@ -539,7 +539,7 @@ window.Page_walks = (() => {
|
|||
const fd = UI.formData(e.target);
|
||||
|
||||
if (!fd.lat || !fd.lon) {
|
||||
UI.toast.warning('Bitte GPS-Position ermitteln (📍).');
|
||||
UI.toast.warning('Bitte GPS-Position ermitteln.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
687
backend/static/js/pages/wiki.js
Normal file
687
backend/static/js/pages/wiki.js
Normal file
|
|
@ -0,0 +1,687 @@
|
|||
/* ============================================================
|
||||
BAN YARO — Hunde-Wiki
|
||||
Rassen-Datenbank, Gesundheit, Recht, Quiz
|
||||
============================================================ */
|
||||
|
||||
window.Page_wiki = (() => {
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// MODUL-STATE
|
||||
// ----------------------------------------------------------
|
||||
let _container = null;
|
||||
let _appState = null;
|
||||
let _tab = 'rassen';
|
||||
let _rassen = [];
|
||||
let _gruppen = [];
|
||||
let _totalBreeds = 0;
|
||||
let _currentOffset = 0;
|
||||
const PAGE_SIZE = 30;
|
||||
let _currentSearch = '';
|
||||
let _currentGruppe = '';
|
||||
let _quizAnswers = {};
|
||||
let _quizStep = 0;
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// HARDCODED: Gesundheits-Inhalte
|
||||
// ----------------------------------------------------------
|
||||
const GESUNDHEIT = [
|
||||
{
|
||||
titel: 'Zecken & FSME',
|
||||
icon: 'skull',
|
||||
text: 'Zecken sind von März bis November aktiv (Spitze April–Juni, September–Oktober). Täglich nach Gassi auf Zecken untersuchen — besonders Ohren, Achseln, Leiste.\n\nZecke entfernen: Zeckenzange ansetzen, nicht drehen, gerade herausziehen. KEINE Öle/Vaseline.\n\nFSME: Impfung für Menschen empfohlen in Risikogebieten (RKI-Karte: rki.de/fsme). Hunde können Borreliose bekommen — Impfung empfohlen.',
|
||||
},
|
||||
{
|
||||
titel: 'Vergiftungen — Sofortmaßnahmen',
|
||||
icon: 'skull',
|
||||
text: 'Verdacht auf Vergiftung: Sofort Tierarzt oder Tiergiftzentrale (Berlin: 030 19240, München: 089 19240).\n\nNICHT versuchen den Hund zum Erbrechen zu bringen ohne tierärztlichen Rat.\n\nHäufige Giftquellen: Schokolade, Trauben/Rosinen, Zwiebeln, Xylitol (Süßungsmittel), Ibuprofen.',
|
||||
},
|
||||
{
|
||||
titel: 'Hitzschlag',
|
||||
icon: 'warning',
|
||||
text: 'Symptome: Starkes Hecheln, Speichelfluss, taumeln, Kollaps.\n\nSofortmaßnahme: In den Schatten, mit lauwarmem (nicht kaltem!) Wasser abkühlen, sofort zum Tierarzt.\n\nHunde NIEMALS im Auto lassen.',
|
||||
},
|
||||
{
|
||||
titel: 'Erste Hilfe Grundlagen',
|
||||
icon: 'first-aid',
|
||||
text: 'Bewusstloser Hund: Atemwege frei? Atemkontrolle.\n\nHerzdruckmassage: 100–120/min, 1/3 Brusttiefe.\n\nBeatmung: Maul zu, in Nase blasen.\n\nBlutung: Druckverband.\n\nKnochenbruch: Immobilisieren, tragen.',
|
||||
},
|
||||
];
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// HARDCODED: Recht & Regeln
|
||||
// ----------------------------------------------------------
|
||||
const RECHT = [
|
||||
{ land: 'Bayern', leine: 'Anleinpflicht im Wald und in Ortschaften', rasse: 'Keine allgemeine Rasseliste (Gefährlichkeitsfeststellung individuell)', steuer: '~100€/Jahr (variiert nach Gemeinde)' },
|
||||
{ land: 'Baden-Württemberg', leine: 'Leinenpflicht in Ortschaften und Parks', rasse: 'American Pitbull Terrier, American Staffordshire Terrier u.a.', steuer: '~100–150€/Jahr' },
|
||||
{ land: 'Berlin', leine: 'Allgemeine Leinenpflicht in öffentlichen Anlagen', rasse: 'Pitbull, Staffordshire, Rottweiler (bedingt)', steuer: '~120€/Jahr (ab 2. Hund: 180€)' },
|
||||
{ land: 'Brandenburg', leine: 'Leinenpflicht in Ortschaften und Wäldern April–Juli', rasse: 'Pitbull, American Staffordshire u.a.', steuer: '~60–100€/Jahr' },
|
||||
{ land: 'Hamburg', leine: 'Allgemeine Leinenpflicht', rasse: 'Pitbull, Rottweiler, Staffordshire u.a.', steuer: '~90€/Jahr (Kampfhund: 900€)' },
|
||||
{ land: 'Hessen', leine: 'Anleinpflicht in Ortschaften', rasse: 'Pitbull u.a. (Liste)', steuer: '~75–120€/Jahr' },
|
||||
{ land: 'NRW', leine: 'Leinenpflicht in bebauten Gebieten', rasse: 'Pitbull, American Staffordshire, Staffordshire Bull Terrier u.a.', steuer: '~100–160€/Jahr' },
|
||||
{ land: 'Niedersachsen', leine: 'Anleinpflicht in Ortschaften', rasse: 'Pitbull u.a.', steuer: '~60–100€/Jahr' },
|
||||
{ land: 'Sachsen', leine: 'Leinenpflicht in Ortschaften und öffentl. Anlagen', rasse: 'Keine staatliche Liste (kommunal)', steuer: '~50–100€/Jahr' },
|
||||
{ land: 'Thüringen', leine: 'Anleinpflicht in Wäldern und Ortschaften', rasse: 'Pitbull u.a.', steuer: '~60–100€/Jahr' },
|
||||
];
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// QUIZ: Fragen
|
||||
// ----------------------------------------------------------
|
||||
const QUIZ_FRAGEN = [
|
||||
{ key: 'groesse', frage: 'Welche Größe passt zu dir?', optionen: [{val:'klein', label:'Klein (unter 10 kg)'}, {val:'mittel', label:'Mittel (10–30 kg)'}, {val:'gross', label:'Groß (über 30 kg)'}] },
|
||||
{ key: 'aktivitaet', frage: 'Wie aktiv bist du?', optionen: [{val:'niedrig', label:'Eher gemütlich'}, {val:'mittel', label:'Regelmäßige Spaziergänge'}, {val:'hoch', label:'Sehr sportlich'}] },
|
||||
{ key: 'erfahrung', frage: 'Wie viel Hundeerfahrung hast du?', optionen: [{val:'anfaenger', label:'Ersthundehalter'}, {val:'fortgeschritten', label:'Erfahren'}, {val:'experte', label:'Profi'}] },
|
||||
{ key: 'kinder', frage: 'Lebst du mit Kindern zusammen?', optionen: [{val:'true', label:'Ja'}, {val:'false', label:'Nein'}] },
|
||||
{ key: 'wohnung', frage: 'Wo wohnst du?', optionen: [{val:'true', label:'Wohnung (ohne Garten)'}, {val:'false', label:'Haus mit Garten'}] },
|
||||
];
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// INIT
|
||||
// ----------------------------------------------------------
|
||||
async function init(container, appState) {
|
||||
_container = container;
|
||||
_appState = appState;
|
||||
await _render();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// REFRESH
|
||||
// ----------------------------------------------------------
|
||||
async function refresh() {
|
||||
// Wiki ist nicht hunde-spezifisch, kein Reload nötig
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// RENDER
|
||||
// ----------------------------------------------------------
|
||||
async function _render() {
|
||||
_container.innerHTML = `
|
||||
<div class="wiki-tab-bar" id="wiki-tab-bar">
|
||||
<button class="wiki-tab-btn${_tab === 'rassen' ? ' active' : ''}" data-tab="rassen">${UI.icon('dog')} Rassen</button>
|
||||
<button class="wiki-tab-btn${_tab === 'gesundheit'? ' active' : ''}" data-tab="gesundheit">${UI.icon('syringe')} Gesundheit</button>
|
||||
<button class="wiki-tab-btn${_tab === 'recht' ? ' active' : ''}" data-tab="recht">${UI.icon('handshake')} Recht</button>
|
||||
<button class="wiki-tab-btn${_tab === 'quiz' ? ' active' : ''}" data-tab="quiz">${UI.icon('star')} Quiz</button>
|
||||
</div>
|
||||
<div id="wiki-content"></div>
|
||||
`;
|
||||
|
||||
_container.querySelector('#wiki-tab-bar').addEventListener('click', e => {
|
||||
const btn = e.target.closest('[data-tab]');
|
||||
if (!btn) return;
|
||||
_tab = btn.dataset.tab;
|
||||
_container.querySelectorAll('.wiki-tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === _tab));
|
||||
_renderTab();
|
||||
});
|
||||
|
||||
await _renderTab();
|
||||
}
|
||||
|
||||
async function _renderTab() {
|
||||
const content = _container.querySelector('#wiki-content');
|
||||
if (!content) return;
|
||||
if (_tab === 'rassen') await _renderRassen(content);
|
||||
else if (_tab === 'gesundheit') _renderGesundheit(content);
|
||||
else if (_tab === 'recht') _renderRecht(content);
|
||||
else if (_tab === 'quiz') _renderQuiz(content);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// TAB: Rassen
|
||||
// ----------------------------------------------------------
|
||||
async function _renderRassen(el) {
|
||||
// Check seeding state first
|
||||
let stats;
|
||||
try {
|
||||
stats = await _apiFetch('/api/wiki/stats');
|
||||
} catch {
|
||||
el.innerHTML = UI.emptyState({ icon: 'warning', title: 'Fehler', text: 'Wiki konnte nicht geladen werden.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stats.seeded) {
|
||||
el.innerHTML = `
|
||||
<div class="wiki-loading-state">
|
||||
<div class="wiki-loading-spinner"></div>
|
||||
<p class="wiki-loading-text">Rassen-Datenbank wird geladen… ${UI.icon('dog')}</p>
|
||||
<p class="wiki-loading-hint">Beim ersten Start werden ~170 Rassen von TheDogAPI abgerufen.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset state when re-rendering the tab fresh
|
||||
_rassen = [];
|
||||
_currentOffset = 0;
|
||||
_currentSearch = '';
|
||||
_currentGruppe = '';
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="wiki-filter-bar">
|
||||
<input class="form-control wiki-search-input" type="search" id="wiki-rassen-search" placeholder="Rasse suchen…">
|
||||
<select class="form-control wiki-gruppe-select" id="wiki-gruppe-select">
|
||||
<option value="">Alle Gruppen</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="wiki-breed-grid" id="wiki-breed-grid"></div>
|
||||
<div id="wiki-mehr-wrap" style="text-align:center;padding:var(--space-4) 0;display:none">
|
||||
<button class="btn btn-secondary" id="wiki-mehr-btn">Mehr laden</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Load initial batch (also populates gruppen)
|
||||
await _loadBreeds(el, true);
|
||||
|
||||
// Search handler with debounce
|
||||
let _searchTimer;
|
||||
el.querySelector('#wiki-rassen-search').addEventListener('input', e => {
|
||||
clearTimeout(_searchTimer);
|
||||
_searchTimer = setTimeout(() => {
|
||||
_currentSearch = e.target.value;
|
||||
_rassen = [];
|
||||
_currentOffset = 0;
|
||||
_loadBreeds(el, true);
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// Gruppe filter handler
|
||||
el.querySelector('#wiki-gruppe-select').addEventListener('change', e => {
|
||||
_currentGruppe = e.target.value;
|
||||
_rassen = [];
|
||||
_currentOffset = 0;
|
||||
_loadBreeds(el, true);
|
||||
});
|
||||
|
||||
// "Mehr laden" button
|
||||
el.querySelector('#wiki-mehr-btn').addEventListener('click', () => {
|
||||
_loadBreeds(el, false);
|
||||
});
|
||||
}
|
||||
|
||||
async function _loadBreeds(el, reset) {
|
||||
const grid = el.querySelector('#wiki-breed-grid');
|
||||
const mehrWrap = el.querySelector('#wiki-mehr-wrap');
|
||||
const mehrBtn = el.querySelector('#wiki-mehr-btn');
|
||||
if (!grid) return;
|
||||
|
||||
if (reset) {
|
||||
grid.innerHTML = `<div class="wiki-breeds-loading">Lade Rassen…</div>`;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
search: _currentSearch,
|
||||
gruppe: _currentGruppe,
|
||||
limit: PAGE_SIZE,
|
||||
offset: _currentOffset,
|
||||
});
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = await _apiFetch(`/api/wiki/rassen?${params}`);
|
||||
} catch {
|
||||
grid.innerHTML = `<p style="color:var(--c-danger);padding:var(--space-4)">Rassen konnten nicht geladen werden.</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Populate Gruppen dropdown (only on first load)
|
||||
if (reset && data.gruppen && data.gruppen.length) {
|
||||
_gruppen = data.gruppen;
|
||||
const sel = el.querySelector('#wiki-gruppe-select');
|
||||
if (sel) {
|
||||
// Preserve current selection
|
||||
const cur = _currentGruppe;
|
||||
sel.innerHTML = `<option value="">Alle Gruppen</option>` +
|
||||
_gruppen.map(g => `<option value="${_esc(g)}"${g === cur ? ' selected' : ''}>${_esc(g)}</option>`).join('');
|
||||
}
|
||||
}
|
||||
|
||||
if (reset) {
|
||||
_rassen = data.breeds;
|
||||
_totalBreeds = data.total;
|
||||
grid.innerHTML = '';
|
||||
} else {
|
||||
_rassen = _rassen.concat(data.breeds);
|
||||
_currentOffset += data.breeds.length;
|
||||
}
|
||||
|
||||
if (reset) {
|
||||
_currentOffset = data.breeds.length;
|
||||
}
|
||||
|
||||
if (reset && _rassen.length === 0) {
|
||||
grid.innerHTML = `<p style="color:var(--c-text-secondary);padding:var(--space-4)">Keine Rassen gefunden.</p>`;
|
||||
if (mehrWrap) mehrWrap.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Render cards
|
||||
const newCards = data.breeds.map(r => _breedCardHtml(r)).join('');
|
||||
if (reset) {
|
||||
grid.innerHTML = newCards;
|
||||
} else {
|
||||
grid.insertAdjacentHTML('beforeend', newCards);
|
||||
}
|
||||
|
||||
// Attach click handlers to newly added cards
|
||||
grid.querySelectorAll('.wiki-breed-card:not([data-bound])').forEach(card => {
|
||||
card.dataset.bound = '1';
|
||||
card.addEventListener('click', () => _openBreedDetail(card.dataset.slug));
|
||||
});
|
||||
|
||||
// Show/hide "Mehr laden"
|
||||
if (mehrWrap) {
|
||||
const shown = _rassen.length;
|
||||
mehrWrap.style.display = shown < _totalBreeds ? 'block' : 'none';
|
||||
if (mehrBtn) mehrBtn.textContent = `Mehr laden (${_totalBreeds - shown} weitere)`;
|
||||
}
|
||||
}
|
||||
|
||||
function _breedCardHtml(r) {
|
||||
const photoHtml = r.foto_url
|
||||
? `<img class="wiki-breed-photo" src="${_esc(r.foto_url)}" loading="lazy" alt="${_esc(r.name)}" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">`
|
||||
: '';
|
||||
const fallbackHtml = `<div class="wiki-breed-photo-fallback" style="${r.foto_url ? 'display:none' : ''}">${UI.icon('dog')}</div>`;
|
||||
|
||||
return `
|
||||
<div class="wiki-breed-card" data-slug="${_esc(r.slug)}">
|
||||
<div class="wiki-breed-photo-wrap">
|
||||
${photoHtml}
|
||||
${fallbackHtml}
|
||||
</div>
|
||||
<div class="wiki-breed-card-body">
|
||||
<div class="wiki-breed-card-name">${_esc(r.name)}</div>
|
||||
<div class="wiki-breed-card-gruppe">${_esc(r.gruppe || '—')}</div>
|
||||
<div class="wiki-breed-badges">
|
||||
<span class="wiki-badge-groesse wiki-badge-groesse--${_esc(r.groesse)}">${_groesseLabel(r.groesse)}</span>
|
||||
<span class="wiki-badge-aktivitaet wiki-badge-aktivitaet--${_esc(r.aktivitaet)}">${_aktivLabel(r.aktivitaet)}</span>
|
||||
<span class="wiki-badge-erfahrung wiki-badge-erfahrung--${_esc(r.erfahrung)}">${_erfahrungLabel(r.erfahrung)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function _openBreedDetail(slug) {
|
||||
let rasse;
|
||||
try {
|
||||
rasse = await _apiFetch(`/api/wiki/rassen/${slug}`);
|
||||
} catch {
|
||||
UI.toast.error('Rasse konnte nicht geladen werden.');
|
||||
return;
|
||||
}
|
||||
|
||||
const berichteHtml = _renderBerichteHtml(rasse.berichte || [], slug);
|
||||
|
||||
// Temperament chips
|
||||
const chips = rasse.temperament
|
||||
? rasse.temperament.split(',').map(t => `<span class="wiki-trait-chip">${_esc(t.trim())}</span>`).join('')
|
||||
: '';
|
||||
|
||||
// Stats row
|
||||
const gewicht = (rasse.gewicht_min_kg && rasse.gewicht_max_kg)
|
||||
? `${rasse.gewicht_min_kg}–${rasse.gewicht_max_kg} kg`
|
||||
: (rasse.gewicht_max_kg ? `bis ${rasse.gewicht_max_kg} kg` : '—');
|
||||
|
||||
const photoHtml = rasse.foto_url
|
||||
? `<img class="wiki-detail-photo" src="${_esc(rasse.foto_url)}" alt="${_esc(rasse.name)}" onerror="this.style.display='none'">`
|
||||
: '';
|
||||
|
||||
const body = `
|
||||
${photoHtml}
|
||||
<div class="wiki-detail-badges">
|
||||
<span class="wiki-badge-groesse wiki-badge-groesse--${_esc(rasse.groesse)}">${_groesseLabel(rasse.groesse)}</span>
|
||||
<span class="wiki-badge-aktivitaet wiki-badge-aktivitaet--${_esc(rasse.aktivitaet)}">${_aktivLabel(rasse.aktivitaet)}</span>
|
||||
<span class="wiki-badge-erfahrung wiki-badge-erfahrung--${_esc(rasse.erfahrung)}">${_erfahrungLabel(rasse.erfahrung)}</span>
|
||||
${rasse.gruppe ? `<span class="badge">${_esc(rasse.gruppe)}</span>` : ''}
|
||||
</div>
|
||||
${rasse.herkunft || rasse.bred_for ? `
|
||||
<div class="wiki-detail-section">
|
||||
${rasse.herkunft ? `<div class="wiki-detail-label">Herkunft</div><p>${_esc(rasse.herkunft)}</p>` : ''}
|
||||
${rasse.bred_for ? `<div class="wiki-detail-label">Ursprüngliche Aufgabe</div><p>${_esc(rasse.bred_for)}</p>` : ''}
|
||||
</div>` : ''}
|
||||
${chips ? `
|
||||
<div class="wiki-detail-section">
|
||||
<div class="wiki-detail-label">Charakter</div>
|
||||
<div class="wiki-trait-chips">${chips}</div>
|
||||
</div>` : ''}
|
||||
<div class="wiki-stat-row">
|
||||
<div class="wiki-stat-item">
|
||||
<span class="wiki-stat-label">Gewicht</span>
|
||||
<span class="wiki-stat-value">${gewicht}</span>
|
||||
</div>
|
||||
<div class="wiki-stat-item">
|
||||
<span class="wiki-stat-label">Lebenserwartung</span>
|
||||
<span class="wiki-stat-value">${_esc(rasse.lebensdauer || '—')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wiki-fit-row">
|
||||
<span>${UI.icon('house-line')} Wohnung: ${rasse.wohnung_geeignet ? UI.icon('check') : UI.icon('x')}</span>
|
||||
<span>${UI.icon('users')} Kinder: ${rasse.kinder_geeignet ? UI.icon('check') : UI.icon('x')}</span>
|
||||
</div>
|
||||
<div class="wiki-detail-section" id="wiki-berichte-section">
|
||||
<div class="wiki-detail-label">Community-Berichte</div>
|
||||
${berichteHtml}
|
||||
</div>
|
||||
${_appState.user
|
||||
? `<button class="btn btn-secondary w-full" id="wiki-bericht-add-btn" style="margin-top:var(--space-3)">+ Eigenen Bericht hinzufügen</button>`
|
||||
: `<p style="color:var(--c-text-secondary);font-size:var(--text-sm);margin-top:var(--space-3)">
|
||||
<a href="#settings" style="color:var(--c-primary)">Anmelden</a>, um einen Bericht zu schreiben.
|
||||
</p>`
|
||||
}
|
||||
`;
|
||||
|
||||
UI.modal.open({ title: _esc(rasse.name), body });
|
||||
|
||||
document.getElementById('wiki-bericht-add-btn')?.addEventListener('click', () => {
|
||||
UI.modal.close();
|
||||
setTimeout(() => _showBerichtForm(slug, rasse.name), 350);
|
||||
});
|
||||
}
|
||||
|
||||
function _renderBerichteHtml(berichte, slug) {
|
||||
if (!berichte || berichte.length === 0) {
|
||||
return `<p style="color:var(--c-text-secondary);font-size:var(--text-sm)">Noch keine Community-Berichte für diese Rasse.</p>`;
|
||||
}
|
||||
return berichte.map(b => `
|
||||
<div class="wiki-bericht-item" data-id="${b.id}">
|
||||
<div class="wiki-bericht-header">
|
||||
<span class="wiki-bericht-autor">${_esc(b.autor)}</span>
|
||||
<span class="wiki-bericht-date">${_formatDate(b.created_at)}</span>
|
||||
${_appState.user && _appState.user.name === b.autor
|
||||
? `<button class="btn btn-danger btn-xs wiki-bericht-del" data-id="${b.id}" data-slug="${_esc(slug)}" style="margin-left:auto;padding:2px 8px;font-size:0.7rem">Löschen</button>`
|
||||
: ''}
|
||||
</div>
|
||||
<div class="wiki-bericht-titel">${_esc(b.titel)}</div>
|
||||
<p class="wiki-bericht-text">${_esc(b.text)}</p>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function _showBerichtForm(slug, rasseName) {
|
||||
const body = `
|
||||
<form id="wiki-bericht-form" autocomplete="off">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Rasse</label>
|
||||
<input class="form-control" type="text" value="${_esc(rasseName)}" disabled>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Titel</label>
|
||||
<input class="form-control" type="text" name="titel" maxlength="120" placeholder="z.B. Mein Erfahrungsbericht" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Bericht</label>
|
||||
<textarea class="form-control" name="text" rows="6" placeholder="Deine Erfahrungen mit dieser Rasse…" required></textarea>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
const footer = `
|
||||
<button type="button" class="btn btn-secondary flex-1" id="wiki-bericht-cancel">Abbrechen</button>
|
||||
<button type="submit" form="wiki-bericht-form" class="btn btn-primary flex-1">Veröffentlichen</button>
|
||||
`;
|
||||
|
||||
UI.modal.open({ title: 'Bericht schreiben', body, footer });
|
||||
document.getElementById('wiki-bericht-cancel')?.addEventListener('click', UI.modal.close);
|
||||
|
||||
const form = document.getElementById('wiki-bericht-form');
|
||||
setTimeout(() => form?.querySelector('[name="titel"]')?.focus(), 150);
|
||||
|
||||
form.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const submitBtn = document.querySelector('[form="wiki-bericht-form"][type="submit"]');
|
||||
const fd = UI.formData(form);
|
||||
|
||||
await UI.asyncButton(submitBtn, async () => {
|
||||
try {
|
||||
await _apiPost('/api/wiki/berichte', { rasse: slug, titel: fd.titel, text: fd.text });
|
||||
UI.modal.close();
|
||||
UI.toast.success('Bericht veröffentlicht!');
|
||||
} catch (err) {
|
||||
UI.toast.error(err.message || 'Fehler beim Veröffentlichen.');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// TAB: Gesundheit
|
||||
// ----------------------------------------------------------
|
||||
function _renderGesundheit(el) {
|
||||
const items = GESUNDHEIT.map((s, i) => `
|
||||
<div class="wiki-section" data-idx="${i}">
|
||||
<div class="wiki-section-header">
|
||||
<span class="wiki-section-icon">${UI.icon(s.icon)}</span>
|
||||
<span class="wiki-section-titel">${_esc(s.titel)}</span>
|
||||
<span class="wiki-section-arrow">${UI.icon('caret-down')}</span>
|
||||
</div>
|
||||
<div class="wiki-section-body" style="display:none">
|
||||
<p style="white-space:pre-wrap;line-height:1.6;color:var(--c-text)">${_esc(s.text)}</p>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
el.innerHTML = `<div class="wiki-accordion">${items}</div>`;
|
||||
|
||||
el.querySelectorAll('.wiki-section').forEach(sec => {
|
||||
sec.querySelector('.wiki-section-header').addEventListener('click', () => {
|
||||
const body = sec.querySelector('.wiki-section-body');
|
||||
const arrow = sec.querySelector('.wiki-section-arrow');
|
||||
const open = body.style.display !== 'none';
|
||||
body.style.display = open ? 'none' : 'block';
|
||||
arrow.innerHTML = open ? UI.icon('caret-down') : UI.icon('caret-up');
|
||||
sec.classList.toggle('open', !open);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// TAB: Recht & Regeln
|
||||
// ----------------------------------------------------------
|
||||
function _renderRecht(el) {
|
||||
const items = RECHT.map((r, i) => `
|
||||
<div class="wiki-section" data-idx="${i}">
|
||||
<div class="wiki-section-header">
|
||||
<span class="wiki-section-icon">${UI.icon('map-pin')}</span>
|
||||
<span class="wiki-section-titel">${_esc(r.land)}</span>
|
||||
<span class="wiki-section-arrow">${UI.icon('caret-down')}</span>
|
||||
</div>
|
||||
<div class="wiki-section-body" style="display:none">
|
||||
<div class="wiki-recht-row"><span class="wiki-recht-label">Leinenpflicht</span><span>${_esc(r.leine)}</span></div>
|
||||
<div class="wiki-recht-row"><span class="wiki-recht-label">Rasseliste</span><span>${_esc(r.rasse)}</span></div>
|
||||
<div class="wiki-recht-row"><span class="wiki-recht-label">Hundesteuer</span><span>${_esc(r.steuer)}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="wiki-accordion">${items}</div>
|
||||
<p class="wiki-disclaimer">Angaben ohne Gewähr — Regelungen ändern sich. Bitte beim zuständigen Ordnungsamt prüfen.</p>
|
||||
`;
|
||||
|
||||
el.querySelectorAll('.wiki-section').forEach(sec => {
|
||||
sec.querySelector('.wiki-section-header').addEventListener('click', () => {
|
||||
const body = sec.querySelector('.wiki-section-body');
|
||||
const arrow = sec.querySelector('.wiki-section-arrow');
|
||||
const open = body.style.display !== 'none';
|
||||
body.style.display = open ? 'none' : 'block';
|
||||
arrow.innerHTML = open ? UI.icon('caret-down') : UI.icon('caret-up');
|
||||
sec.classList.toggle('open', !open);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// TAB: Quiz
|
||||
// ----------------------------------------------------------
|
||||
function _renderQuiz(el) {
|
||||
_quizAnswers = {};
|
||||
_quizStep = 0;
|
||||
_renderQuizStep(el);
|
||||
}
|
||||
|
||||
function _renderQuizStep(el) {
|
||||
if (_quizStep >= QUIZ_FRAGEN.length) {
|
||||
_loadQuizResult(el);
|
||||
return;
|
||||
}
|
||||
|
||||
const frage = QUIZ_FRAGEN[_quizStep];
|
||||
const progress = Math.round((_quizStep / QUIZ_FRAGEN.length) * 100);
|
||||
|
||||
const optionsHtml = frage.optionen.map(o => `
|
||||
<button class="wiki-quiz-option${_quizAnswers[frage.key] === o.val ? ' selected' : ''}"
|
||||
data-key="${_esc(frage.key)}" data-val="${_esc(o.val)}">
|
||||
${_esc(o.label)}
|
||||
</button>
|
||||
`).join('');
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="wiki-quiz-wrap">
|
||||
<div class="wiki-quiz-progress-bar">
|
||||
<div class="wiki-quiz-progress" style="width:${progress}%"></div>
|
||||
</div>
|
||||
<p class="wiki-quiz-step-info">Frage ${_quizStep + 1} von ${QUIZ_FRAGEN.length}</p>
|
||||
<p class="wiki-quiz-frage">${_esc(frage.frage)}</p>
|
||||
<div class="wiki-quiz-options">${optionsHtml}</div>
|
||||
<div class="wiki-quiz-nav">
|
||||
${_quizStep > 0
|
||||
? `<button class="btn btn-secondary" id="quiz-back">Zurück</button>`
|
||||
: '<span></span>'}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
el.querySelectorAll('.wiki-quiz-option').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
_quizAnswers[btn.dataset.key] = btn.dataset.val;
|
||||
_quizStep++;
|
||||
_renderQuizStep(el);
|
||||
});
|
||||
});
|
||||
|
||||
el.querySelector('#quiz-back')?.addEventListener('click', () => {
|
||||
_quizStep--;
|
||||
const prevKey = QUIZ_FRAGEN[_quizStep].key;
|
||||
delete _quizAnswers[prevKey];
|
||||
_renderQuizStep(el);
|
||||
});
|
||||
}
|
||||
|
||||
async function _loadQuizResult(el) {
|
||||
el.innerHTML = `<div style="text-align:center;padding:var(--space-8)">Berechne Ergebnis…</div>`;
|
||||
|
||||
const params = new URLSearchParams(_quizAnswers).toString();
|
||||
let data;
|
||||
try {
|
||||
data = await _apiFetch(`/api/wiki/quiz/result?${params}`);
|
||||
} catch {
|
||||
el.innerHTML = UI.emptyState({ icon: 'warning', title: 'Fehler', text: 'Ergebnis konnte nicht geladen werden.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const cardsHtml = data.results.map(r => {
|
||||
const photoHtml = r.foto_url
|
||||
? `<img class="wiki-quiz-result-photo" src="${_esc(r.foto_url)}" loading="lazy" alt="${_esc(r.name)}" onerror="this.style.display='none'">`
|
||||
: `<div class="wiki-quiz-result-photo-fallback">${UI.icon('dog')}</div>`;
|
||||
return `
|
||||
<div class="wiki-quiz-result-card">
|
||||
<div class="wiki-quiz-result-photo-wrap">${photoHtml}</div>
|
||||
<div class="wiki-quiz-result-card-body">
|
||||
<div class="wiki-quiz-result-name">${_esc(r.name)}</div>
|
||||
<div class="wiki-quiz-result-gruppe">${_esc(r.gruppe || '')}</div>
|
||||
<div class="wiki-breed-badges" style="margin:var(--space-2) 0">
|
||||
<span class="wiki-badge-groesse wiki-badge-groesse--${_esc(r.groesse)}">${_groesseLabel(r.groesse)}</span>
|
||||
<span class="wiki-badge-aktivitaet wiki-badge-aktivitaet--${_esc(r.aktivitaet)}">${_aktivLabel(r.aktivitaet)}</span>
|
||||
</div>
|
||||
${r.temperament ? `<p class="wiki-quiz-result-char">${_esc(r.temperament.split(',').slice(0,4).join(', '))}</p>` : ''}
|
||||
<div class="wiki-fit-row" style="font-size:var(--text-xs);margin-top:var(--space-1)">
|
||||
<span>${UI.icon('house-line')} ${r.wohnung_geeignet ? 'Wohnung' : 'Haus'}</span>
|
||||
<span>${UI.icon('users')} ${r.kinder_geeignet ? 'Kinderfreundlich' : 'Erfahrung nötig'}</span>
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-sm wiki-quiz-mehr" data-slug="${_esc(r.slug)}" style="margin-top:var(--space-2)">Mehr erfahren</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="wiki-quiz-wrap">
|
||||
<div class="wiki-quiz-progress-bar">
|
||||
<div class="wiki-quiz-progress" style="width:100%"></div>
|
||||
</div>
|
||||
<h3 style="margin:var(--space-4) 0 var(--space-2);text-align:center">Deine Top 3 Rassen</h3>
|
||||
<div class="wiki-quiz-results">${cardsHtml}</div>
|
||||
<button class="btn btn-secondary w-full" id="quiz-restart" style="margin-top:var(--space-4)">Quiz neu starten</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
el.querySelectorAll('.wiki-quiz-mehr').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
_tab = 'rassen';
|
||||
_container.querySelectorAll('.wiki-tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === 'rassen'));
|
||||
_openBreedDetail(btn.dataset.slug);
|
||||
});
|
||||
});
|
||||
|
||||
el.querySelector('#quiz-restart')?.addEventListener('click', () => {
|
||||
_renderQuiz(el);
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// HELPER: API-Fetch
|
||||
// ----------------------------------------------------------
|
||||
async function _apiFetch(url) {
|
||||
const resp = await fetch(url, { credentials: 'include' });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function _apiPost(url, body) {
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// HELPER: Labels
|
||||
// ----------------------------------------------------------
|
||||
function _groesseLabel(g) {
|
||||
return { klein: 'Klein', mittel: 'Mittel', gross: 'Groß', sehr_gross: 'Sehr groß' }[g] || g;
|
||||
}
|
||||
|
||||
function _aktivLabel(a) {
|
||||
return { niedrig: 'Ruhig', mittel: 'Aktiv', hoch: 'Sportlich', sehr_hoch: 'Sehr aktiv' }[a] || a;
|
||||
}
|
||||
|
||||
function _erfahrungLabel(e) {
|
||||
return { anfaenger: 'Anfänger', fortgeschritten: 'Erfahren', experte: 'Experte' }[e] || e;
|
||||
}
|
||||
|
||||
function _formatDate(iso) {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
} catch { return iso; }
|
||||
}
|
||||
|
||||
function _esc(str) {
|
||||
if (!str) return '';
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// PUBLIC
|
||||
// ----------------------------------------------------------
|
||||
return { init, refresh };
|
||||
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue