banyaro/backend/static/js/pages/friends.js
rene b58789373c Sprint 12: UI-Vereinheitlichung + Läufigkeits-Tracker
- by-tabs/by-tab: einheitliche Tab/Pill-Navigation in allen Seiten
- by-section-label, by-toolbar: einheitliche Section-Labels und Toolbars
- Design-Tokens: fehlende --c-amber, --c-primary-soft ergänzt, Fallback-Werte entfernt
- sitting.js: sitting-layout für konsistentes flush-Layout (wie walks)
- Läufigkeits-Tracker: neuer Health-Tab für Hündinnen mit Zyklusvorhersage,
  Timeline vergangener Läufigkeiten, Erinnerungen und auto-berechnetem Nächst-Datum
- emptyState-Bug: icon-Parameter muss SVG sein, nicht Icon-Name (dog/bell/warning gefixt)
- SW-Cache: by-v103, APP_VER: 79
2026-04-16 22:31:33 +02:00

567 lines
23 KiB
JavaScript

/* ============================================================
BAN YARO — Freunde
============================================================ */
window.Page_friends = (() => {
let _container = null;
let _appState = null;
let _searchTimer = null;
// ----------------------------------------------------------
async function init(container, appState, params = {}) {
_container = container;
_appState = appState;
_render(params.suche || null);
}
function refresh() { _loadFriends(); }
function onDogChange() {}
// ----------------------------------------------------------
// HAUPT-RENDER
// ----------------------------------------------------------
function _render(prefill = null) {
const myName = _appState?.user?.name || '';
const myLink = `${location.origin}/#friends?suche=${encodeURIComponent(myName)}`;
_container.innerHTML = `
<div style="max-width:520px;margin:0 auto;padding:var(--space-4)">
<!-- Mein Freundes-Link -->
<div class="card" style="margin-bottom:var(--space-5);padding:var(--space-4)">
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)">
<div style="width:36px;height:36px;border-radius:50%;flex-shrink:0;
background:var(--c-primary-subtle);
display:flex;align-items:center;justify-content:center">
<svg style="width:18px;height:18px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#link"></use>
</svg>
</div>
<div>
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text)">Dein Freundes-Link</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
Teile ihn — der andere tippt drauf und findet dich sofort.
</div>
</div>
</div>
<div style="display:flex;gap:var(--space-2)">
<div style="flex:1;padding:var(--space-2) var(--space-3);
background:var(--c-surface-2);border-radius:var(--radius-md);
font-size:var(--text-xs);color:var(--c-text-secondary);
overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
banyaro.app/#friends?suche=${_esc(encodeURIComponent(myName))}
</div>
<button class="btn btn-ghost btn-sm" id="fr-copy-btn" title="Link kopieren">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#link"></use></svg>
</button>
<button class="btn btn-primary btn-sm" id="fr-share-btn" title="Link teilen">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paper-plane-tilt"></use></svg>
Teilen
</button>
</div>
</div>
<!-- Suche -->
<div style="position:relative;margin-bottom:var(--space-2)">
<svg class="ph-icon" style="position:absolute;left:var(--space-3);top:50%;
transform:translateY(-50%);color:var(--c-text-muted);pointer-events:none"
aria-hidden="true">
<use href="/icons/phosphor.svg#magnifying-glass"></use>
</svg>
<input id="fr-search" type="search" autocomplete="off"
placeholder="Namen eines Hundebesitzers suchen…"
value="${_esc(prefill || '')}"
style="width:100%;box-sizing:border-box;
padding:var(--space-3) var(--space-3) var(--space-3) 2.5rem;
border:1.5px solid var(--c-border);border-radius:var(--radius-lg);
font-size:var(--text-sm);font-family:inherit;
background:var(--c-surface);color:var(--c-text)">
</div>
<p style="font-size:var(--text-xs);color:var(--c-text-muted);
margin:0 0 var(--space-4) var(--space-1)">
Tipp: Lass dir den Freundes-Link einer anderen Person schicken — dann klappt die Suche automatisch.
</p>
<div id="fr-search-results"></div>
<!-- Eingehende Anfragen -->
<div id="fr-incoming"></div>
<!-- Ausstehende Anfragen -->
<div id="fr-outgoing"></div>
<!-- Freundesliste -->
<div id="fr-list"></div>
</div>
`;
// Copy-Button
_container.querySelector('#fr-copy-btn')?.addEventListener('click', () => {
navigator.clipboard.writeText(myLink).then(() => {
UI.toast.success('Link kopiert!');
}).catch(() => {
UI.toast.info('Link: ' + myLink);
});
});
// Share-Button (Web Share API, Fallback: Copy)
_container.querySelector('#fr-share-btn')?.addEventListener('click', async () => {
if (navigator.share) {
try {
await navigator.share({
title: `${myName} auf Ban Yaro`,
text: `Füge mich auf Ban Yaro als Freund hinzu!`,
url: myLink,
});
} catch { /* abgebrochen */ }
} else {
navigator.clipboard.writeText(myLink).then(() => {
UI.toast.success('Link kopiert!');
});
}
});
// Suche
const searchInput = _container.querySelector('#fr-search');
searchInput.addEventListener('input', e => {
clearTimeout(_searchTimer);
const q = e.target.value.trim();
if (q.length < 2) {
_container.querySelector('#fr-search-results').innerHTML = '';
return;
}
_searchTimer = setTimeout(() => _doSearch(q), 380);
});
// Prefill aus URL-Parameter → sofort suchen
if (prefill && prefill.length >= 2) {
_doSearch(prefill);
}
_loadFriends();
}
// ----------------------------------------------------------
// DATEN LADEN
// ----------------------------------------------------------
async function _loadFriends() {
try {
const data = await API.friends.list();
_renderIncoming(data.incoming || []);
_renderOutgoing(data.outgoing || []);
_renderFriends(data.friends || []);
_updateBadge((data.incoming || []).length);
} catch { /* 401 wird vom Auth-Guard abgefangen */ }
}
function _updateBadge(count) {
const el = document.getElementById('friends-badge');
if (!el) return;
el.textContent = count;
el.style.display = count > 0 ? '' : 'none';
}
// ----------------------------------------------------------
// EINGEHENDE ANFRAGEN
// ----------------------------------------------------------
function _renderIncoming(list) {
const el = _container.querySelector('#fr-incoming');
if (!list.length) { el.innerHTML = ''; return; }
el.innerHTML = `
<div style="margin-bottom:var(--space-5)">
<div class="by-section-label">Anfragen · ${list.length}</div>
${list.map(r => `
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3);
border-left:3px solid var(--c-primary)">
<div style="display:flex;align-items:center;gap:var(--space-3)">
${_userAvatar(r.requester_name, r.dogs?.[0])}
<div style="flex:1;min-width:0">
<div style="font-weight:var(--weight-semibold);color:var(--c-text)">
${_esc(r.requester_name)}
</div>
${_dogPills(r.dogs, 2)}
</div>
<div style="display:flex;gap:var(--space-2);flex-shrink:0">
<button class="btn btn-primary btn-sm"
onclick="Page_friends._accept(${r.id})" title="Annehmen">
<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})" title="Ablehnen">
<svg class="ph-icon"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
</div>
</div>
</div>
`).join('')}
</div>
`;
}
// ----------------------------------------------------------
// GESENDETE ANFRAGEN
// ----------------------------------------------------------
function _renderOutgoing(list) {
const el = _container.querySelector('#fr-outgoing');
if (!list.length) { el.innerHTML = ''; return; }
el.innerHTML = `
<div style="margin-bottom:var(--space-5)">
<div class="by-section-label">Gesendet</div>
${list.map(r => `
<div class="card" style="padding:var(--space-3) var(--space-4);
margin-bottom:var(--space-2)">
<div style="display:flex;align-items:center;gap:var(--space-3)">
<div style="width:36px;height:36px;border-radius:50%;
background:var(--c-surface-2);
display:flex;align-items:center;justify-content:center;
font-weight:var(--weight-bold);color:var(--c-text-secondary);
font-size:var(--text-sm);flex-shrink:0">
${_esc((r.addressee_name || '?')[0].toUpperCase())}
</div>
<div style="flex:1">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text)">${_esc(r.addressee_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">Anfrage ausstehend</div>
</div>
<button class="btn btn-ghost btn-sm"
onclick="Page_friends._cancel(${r.id})" title="Zurückziehen">
<svg class="ph-icon"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
</div>
</div>
`).join('')}
</div>
`;
}
// ----------------------------------------------------------
// FREUNDESLISTE
// ----------------------------------------------------------
function _renderFriends(list) {
const el = _container.querySelector('#fr-list');
if (!list.length) {
el.innerHTML = `
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
<svg class="ph-icon" style="width:48px;height:48px;color:var(--c-border);
margin-bottom:var(--space-3)" aria-hidden="true">
<use href="/icons/phosphor.svg#paw-print"></use>
</svg>
<p style="font-size:var(--text-base);font-weight:var(--weight-semibold);
color:var(--c-text);margin:0 0 var(--space-2)">Noch keine Hundefreunde</p>
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0">
Suche oben nach anderen Hundebesitzern und schick ihnen eine Anfrage.
</p>
</div>
`;
return;
}
el.innerHTML = `
<div>
<div class="by-section-label">Freunde · ${list.length}</div>
${list.map(f => _friendCard(f)).join('')}
</div>
`;
// Klick auf Karte → Mini-Profil
el.querySelectorAll('.fr-card').forEach(card => {
card.addEventListener('click', e => {
if (e.target.closest('button')) return; // Buttons nicht überschreiben
const fid = parseInt(card.dataset.friendId);
const fname = card.dataset.friendName;
const fdogs = JSON.parse(card.dataset.dogs || '[]');
_showProfile(fid, fname, fdogs);
});
});
}
function _friendCard(f) {
const dogs = f.dogs || [];
return `
<div class="card fr-card" style="padding:var(--space-4);margin-bottom:var(--space-3);cursor:pointer;
transition:box-shadow 0.15s"
data-friend-id="${f.friend_id}"
data-friend-name="${_esc(f.friend_name)}"
data-dogs="${_esc(JSON.stringify(dogs))}">
<div style="display:flex;align-items:center;gap:var(--space-3)">
<!-- Avatar (erstes Hunde-Foto oder Initiale) -->
${_userAvatar(f.friend_name, dogs[0])}
<!-- Name + Hunde -->
<div style="flex:1;min-width:0">
<div style="font-weight:var(--weight-semibold);color:var(--c-text);
margin-bottom:var(--space-1)">
${_esc(f.friend_name)}
</div>
${dogs.length
? `<div style="display:flex;flex-wrap:wrap;gap:var(--space-1);align-items:center">
${_dogPills(dogs, 3)}
</div>`
: `<div style="font-size:var(--text-xs);color:var(--c-text-muted)">Noch kein Hund eingetragen</div>`
}
</div>
<!-- Aktionen -->
<div style="display:flex;gap:var(--space-1);flex-shrink:0">
<button class="btn btn-ghost btn-sm"
onclick="Page_friends._openChat(${f.friend_id})"
title="Nachricht schreiben">
<svg class="ph-icon"><use href="/icons/phosphor.svg#chat-circle-dots"></use></svg>
</button>
<svg class="ph-icon" style="width:16px;height:16px;color:var(--c-border);
align-self:center;flex-shrink:0" aria-hidden="true">
<use href="/icons/phosphor.svg#caret-down"></use>
</svg>
</div>
</div>
<!-- Hunde-Foto-Zeile wenn mehrere Hunde Fotos haben -->
${_dogPhotoRow(dogs)}
</div>
`;
}
function _dogPhotoRow(dogs) {
const withPhotos = dogs.filter(d => d.foto_url);
if (withPhotos.length < 2) return ''; // 1 Foto schon im Avatar, < 2 lohnt sich nicht
return `
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-3);
padding-top:var(--space-3);border-top:1px solid var(--c-border)">
${withPhotos.slice(0, 4).map(d => `
<div style="text-align:center">
<img src="${_esc(d.foto_url)}" alt="${_esc(d.name)}"
style="width:44px;height:44px;border-radius:50%;object-fit:cover;
border:2px solid var(--c-surface)">
<div style="font-size:10px;color:var(--c-text-muted);margin-top:2px;
max-width:44px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
${_esc(d.name)}
</div>
</div>
`).join('')}
</div>
`;
}
// ----------------------------------------------------------
// MINI-PROFIL MODAL
// ----------------------------------------------------------
function _showProfile(friendId, friendName, dogs) {
const dogsHTML = dogs.length
? `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));
gap:var(--space-3);margin-top:var(--space-4)">
${dogs.map(d => `
<div style="text-align:center">
${d.foto_url
? `<img src="${_esc(d.foto_url)}" alt="${_esc(d.name)}"
style="width:72px;height:72px;border-radius:50%;object-fit:cover;
border:2px solid var(--c-primary);margin-bottom:var(--space-2)">`
: `<div style="width:72px;height:72px;border-radius:50%;
background:var(--c-surface-2);
display:flex;align-items:center;justify-content:center;
font-size:1.75rem;margin:0 auto var(--space-2)">🐕</div>`
}
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text)">${_esc(d.name)}</div>
${d.rasse
? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">${_esc(d.rasse)}</div>`
: ''}
</div>
`).join('')}
</div>`
: `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-top:var(--space-4)">
Noch kein Hund eingetragen.
</p>`;
UI.modal.open({
title: _esc(friendName),
body: `
<div>
<div class="by-section-label">${dogs.length === 1 ? 'Hund' : 'Hunde'}</div>
${dogsHTML}
</div>
`,
footer: `
<button class="btn btn-primary" id="modal-chat-btn" form="">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#chat-circle-dots"></use></svg>
Nachricht schreiben
</button>
<button class="btn btn-ghost" id="modal-remove-btn" form=""
style="color:var(--c-danger)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#user-minus"></use></svg>
Entfernen
</button>
`,
});
document.getElementById('modal-chat-btn')?.addEventListener('click', () => {
UI.modal.close();
_openChat(friendId);
});
document.getElementById('modal-remove-btn')?.addEventListener('click', async () => {
UI.modal.close();
await _removeFriend(friendId, friendName);
});
}
// ----------------------------------------------------------
// SUCHE
// ----------------------------------------------------------
async function _doSearch(q) {
const el = _container.querySelector('#fr-search-results');
try {
const results = await API.friends.search(q);
if (!results.length) {
el.innerHTML = `
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-4);
text-align:center;color:var(--c-text-muted);
font-size:var(--text-sm)">
Kein Nutzer gefunden.
</div>`;
return;
}
el.innerHTML = `
<div class="card" style="margin-bottom:var(--space-4);overflow:hidden">
${results.map((u, i) => `
<div style="display:flex;align-items:center;gap:var(--space-3);
padding:var(--space-3) var(--space-4);
${i < results.length - 1 ? 'border-bottom:1px solid var(--c-border)' : ''}">
${_userAvatar(u.name, null)}
<div style="flex:1;min-width:0">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text)">${_esc(u.name)}</div>
${u.dogs?.length
? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
${u.dogs.map(d => _esc(d.name) + (d.rasse ? ` · ${_esc(d.rasse)}` : '')).join(' &nbsp;|&nbsp; ')}
</div>`
: ''}
</div>
<button class="btn btn-primary btn-sm fr-add-btn"
data-user-id="${u.id}" data-user-name="${_esc(u.name)}">
<svg class="ph-icon"><use href="/icons/phosphor.svg#user-plus"></use></svg>
Anfrage
</button>
</div>
`).join('')}
</div>
`;
el.querySelectorAll('.fr-add-btn').forEach(btn => {
btn.addEventListener('click', () => _sendRequest(parseInt(btn.dataset.userId), btn));
});
} catch { el.innerHTML = ''; }
}
// ----------------------------------------------------------
// AKTIONEN
// ----------------------------------------------------------
async function _sendRequest(userId, btn) {
btn.disabled = true;
btn.innerHTML = `<svg class="ph-icon"><use href="/icons/phosphor.svg#spinner"></use></svg>`;
try {
await API.friends.sendRequest(userId);
UI.toast.success('Freundschaftsanfrage gesendet!');
_container.querySelector('#fr-search').value = '';
_container.querySelector('#fr-search-results').innerHTML = '';
await _loadFriends();
} catch (e) {
UI.toast.error(e.message || 'Fehler beim Senden.');
btn.disabled = false;
btn.innerHTML = `<svg class="ph-icon"><use href="/icons/phosphor.svg#user-plus"></use></svg> Anfrage`;
}
}
async function _accept(id) {
try {
await API.friends.accept(id);
UI.toast.success('Freundschaft angenommen!');
await _loadFriends();
} catch (e) { UI.toast.error(e.message); }
}
async function _decline(id) {
try {
await API.friends.decline(id);
await _loadFriends();
} catch (e) { UI.toast.error(e.message); }
}
async function _cancel(id) {
try {
await API.friends.decline(id);
await _loadFriends();
} catch (e) { UI.toast.error(e.message); }
}
async function _removeFriend(userId, name) {
const ok = await UI.modal.confirm({
title: 'Freund entfernen?',
message: `${name} wird aus deiner Freundesliste entfernt.`,
confirmText: 'Entfernen',
});
if (!ok) return;
try {
await API.friends.remove(userId);
UI.toast.info('Freund entfernt.');
await _loadFriends();
} catch (e) { UI.toast.error(e.message); }
}
async function _openChat(userId) {
try {
const { conversation_id } = await API.chat.start(userId);
App.navigate('chat', true, { conversation_id });
} catch (e) { UI.toast.error(e.message); }
}
// ----------------------------------------------------------
// RENDER-HELPERS
// ----------------------------------------------------------
function _userAvatar(name, firstDog) {
if (firstDog?.foto_url) {
return `<img src="${_esc(firstDog.foto_url)}" alt="${_esc(firstDog.name)}"
style="width:44px;height:44px;border-radius:50%;object-fit:cover;
border:2px solid var(--c-primary);flex-shrink:0">`;
}
return `
<div style="width:44px;height:44px;border-radius:50%;flex-shrink:0;
background:var(--c-primary-subtle);
border:2px solid var(--c-primary);
display:flex;align-items:center;justify-content:center;
font-weight:var(--weight-bold);color:var(--c-primary)">
${_esc((name || '?')[0].toUpperCase())}
</div>`;
}
function _dogPills(dogs, max) {
if (!dogs?.length) return '';
const visible = dogs.slice(0, max);
const rest = dogs.length - max;
return `
<div style="display:flex;flex-wrap:wrap;gap:4px;align-items:center">
${visible.map(d => `
<span style="font-size:10px;padding:1px 6px;border-radius:var(--radius-full);
background:var(--c-surface-2);color:var(--c-text-secondary)">
🐕 ${_esc(d.name)}${d.rasse ? ` · ${_esc(d.rasse)}` : ''}
</span>
`).join('')}
${rest > 0 ? `<span style="font-size:10px;color:var(--c-text-muted)">+${rest}</span>` : ''}
</div>
`;
}
function _esc(s) {
if (!s) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ----------------------------------------------------------
return { init, refresh, onDogChange, _accept, _decline, _cancel, _removeFriend, _openChat };
})();