banyaro/backend/static/js/pages/friends.js
rene 2d98eb9374 Fix: Friends-Avatare wieder Original-URL (kein Preview), SW by-v1111
User-Report: trotz onerror-Fallback weiter Fragezeichen.

Ursache: Das _preview.webp-System wurde damals nur konsequent für
Diary-Uploads ausgerollt. User-Avatare und Hund-Profilbilder haben
keine Preview-Variante → 404 vom _preview triggert kurz das
Browser-Default-Broken-Image-Icon BEVOR der onerror-Fallback das
Original lädt (Race-Condition).

Pragmatischer Fix: Preview-System in friends.js rückgebaut. Bilder
werden direkt mit Original-URL geladen. Performance kommt durch:
- loading=\"lazy\" (off-screen Bilder erst beim Scrollen)
- decoding=\"async\" (Main-Thread bleibt frei)
- onerror=\"this.style.display='none'\" (kaputte Bilder verschwinden
  statt Fragezeichen zu zeigen)

UI.previewUrl + UI.previewFallback bleiben als Helper verfügbar
für später falls das Preview-System app-weit ausgerollt wird.
2026-05-27 09:25:19 +02:00

955 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ============================================================
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) {
if (!_appState.user) {
_container.innerHTML = UI.emptyState({
icon: UI.icon('users'),
title: 'Anmelden erforderlich',
text: 'Melde dich an, um Freunde zu finden und Anfragen zu verwalten.',
action: `<button class="btn btn-primary" onclick="App.navigate('settings')">Anmelden</button>`,
});
return;
}
const myName = _appState?.user?.name || '';
const myLink = `${location.origin}/#friends?suche=${encodeURIComponent(myName)}`;
_container.innerHTML = `
<div>
<!-- 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="fill:currentColor;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 class="text-xs-secondary">
Teile ihn — der andere tippt drauf und findet dich sofort.
</div>
</div>
</div>
<div class="flex-gap-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>
<!-- Aktivitäten-Feed -->
<div id="fr-activity"></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() {
if (!_appState.user) return;
try {
const data = await API.friends.list();
_renderIncoming(data.incoming || []);
_renderOutgoing(data.outgoing || []);
_renderFriends(data.friends || []);
_updateBadge((data.incoming || []).length);
} catch { /* silent — 401 bei abgemeldeter Session */ }
_loadActivity();
}
async function _loadActivity() {
if (!_appState.user) return;
const el = _container.querySelector('#fr-activity');
if (!el) return;
// Ladeindikator
el.innerHTML = `
<div style="margin-top:var(--space-6)">
<div class="by-section-label">Aktivitäten</div>
<div style="text-align:center;padding:var(--space-6) 0;color:var(--c-text-muted);
font-size:var(--text-sm)">
<svg class="ph-icon" style="width:20px;height:20px;animation:spin 1s linear infinite"
aria-hidden="true"><use href="/icons/phosphor.svg#spinner"></use></svg>
</div>
</div>
`;
try {
const items = await API.friends.activity();
_renderActivity(items || []);
} catch {
el.innerHTML = '';
}
}
let _activityFilter = 'alle';
let _activityAll = [];
function _renderActivity(items) {
_activityAll = items;
const el = _container.querySelector('#fr-activity');
if (!el) return;
const FILTERS = [
{ key: 'alle', label: 'Alle' },
{ key: 'walk', label: 'Gassi-Treffen' },
{ key: 'forum', label: 'Forum' },
{ key: 'health', label: 'Gesundheit' },
{ key: 'new_dog', label: 'Neuer Hund' },
];
const chips = FILTERS.map(f => `
<button class="rk-chip${_activityFilter === f.key ? ' active' : ''}"
data-af="${f.key}">${f.label}</button>
`).join('');
const filtered = _activityFilter === 'alle'
? items
: items.filter(i => i.type === _activityFilter);
el.innerHTML = `
<div style="margin-top:var(--space-6)">
<div class="by-section-label">Aktivitäten</div>
<div class="rk-filter-group" style="margin-bottom:var(--space-3);flex-wrap:wrap">
${chips}
</div>
${!filtered.length
? `<div style="text-align:center;padding:var(--space-8) var(--space-4)">
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0">
Keine Einträge in dieser Kategorie.
</p>
</div>`
: `<div class="fr-activity-timeline">
${filtered.map(item => _activityItem(item)).join('')}
</div>`
}
</div>
`;
el.querySelectorAll('[data-af]').forEach(btn => {
btn.addEventListener('click', () => {
_activityFilter = btn.dataset.af;
_renderActivity(_activityAll);
});
});
el.querySelectorAll('.fr-activity-item[data-nav]').forEach(btn => {
btn.addEventListener('click', () => {
const page = btn.dataset.nav;
const entryId = btn.dataset.entryId ? parseInt(btn.dataset.entryId) : null;
const type = btn.dataset.type;
if (!page) return;
if (entryId && type === 'diary') {
App.callModule('diary', 'openDetail', entryId);
} else if (entryId && type === 'walk') {
App.callModule('walks', 'openDetail', entryId);
} else if (entryId && type === 'forum') {
App.callModule('forum', 'openThread', entryId);
} else {
App.navigate(page);
}
});
});
}
const _ACTIVITY_PAGE = {
health: 'health',
walk: 'walks',
forum: 'forum',
new_dog: null,
};
function _activityItem(item) {
const ago = _timeAgo(item.created_at);
const text = item.text || '';
const page = _ACTIVITY_PAGE[item.type] || '';
const dogLabel = item.dog_name
? `<span class="fr-activity-dog">${_esc(item.dog_name)}</span>`
: '';
const avatar = item.dog_foto
? `<img src="${_esc(item.dog_foto)}" alt="${_esc(item.dog_name || '')}"
loading="lazy" decoding="async" onerror="this.style.display='none'"
class="fr-activity-avatar">`
: item.avatar_url
? `<img src="${_esc(item.avatar_url)}" alt="${_esc(item.user_name)}"
loading="lazy" decoding="async" onerror="this.style.display='none'"
class="fr-activity-avatar">`
: `<div class="fr-activity-avatar fr-activity-avatar--initial">
${_esc((item.user_name || '?')[0].toUpperCase())}
</div>`;
const tag = page ? `button type="button"` : `div`;
return `
<${tag} class="fr-activity-item${page ? ' fr-activity-item--link' : ''}"
${page ? `data-nav="${page}"` : ''}
${item.entry_id ? `data-entry-id="${item.entry_id}"` : ''}
data-type="${item.type}">
<div class="fr-activity-avatar-wrap">
${avatar}
<div class="fr-activity-icon-badge">
<svg class="ph-icon" style="width:10px;height:10px" aria-hidden="true">
<use href="/icons/phosphor.svg#${_esc(item.icon)}"></use>
</svg>
</div>
</div>
<div class="fr-activity-body">
<div class="fr-activity-meta">
<span class="fr-activity-user">${_esc(item.user_name)}</span>
${dogLabel}
</div>
${text ? `<div class="fr-activity-text">${_esc(text)}</div>` : ''}
<div class="fr-activity-time">${_esc(ago)}</div>
</div>
</${page ? 'button' : 'div'}>
`;
}
function _timeAgo(iso) {
if (!iso) return '';
const diff = Math.floor((Date.now() - new Date(iso + (iso.endsWith('Z') ? '' : 'Z')).getTime()) / 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`;
if (diff < 86400 * 7) return `vor ${Math.floor(diff / 86400)} Tagen`;
return new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
}
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);flex-wrap:wrap">
${_userAvatar(r.requester_name, r.dogs?.[0], r.avatar_url)}
<div style="flex:1;min-width:120px">
<div style="font-weight:var(--weight-semibold);color:var(--c-text);
overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
${_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 class="flex-1">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text)">${_esc(r.addressee_name)}</div>
<div class="text-xs-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 = _emptyState(
'users-three',
'Noch keine Freunde',
'Verbinde dich mit anderen Hundebesitzern. Teile Routen, sieh Aktivitäten und schreib Nachrichten.',
`<button class="btn btn-primary" id="fr-empty-search">Freunde suchen</button>`
);
el.querySelector('#fr-empty-search')?.addEventListener('click', () => {
_container.querySelector('#fr-search')?.focus();
});
return;
}
el.innerHTML = `
<div>
<div class="by-section-label">Freunde · ${list.length}</div>
${list.map(f => _friendCard(f)).join('')}
</div>
`;
// Notiz-Buttons
el.querySelectorAll('.fr-note-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const id = parseInt(btn.dataset.frNoteId);
const name = btn.dataset.frNoteName || '';
_openNoteModal('friends', id, name, null);
});
});
// 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 || '[]');
const fprofile = JSON.parse(card.dataset.profile || '{}');
_showProfile(fid, fname, fdogs, fprofile);
});
});
}
function _friendCard(f) {
const dogs = f.dogs || [];
const profile = {
bio: f.bio || null,
wohnort: f.wohnort || null,
erfahrung: f.erfahrung || null,
social_link: f.social_link || null,
profil_sichtbarkeit: f.profil_sichtbarkeit || null,
avatar_url: f.avatar_url || null,
};
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))}"
data-profile="${_esc(JSON.stringify(profile))}">
<div style="display:flex;align-items:center;gap:var(--space-3)">
<!-- Avatar (User-Avatar, erstes Hunde-Foto oder Initiale) -->
${_userAvatar(f.friend_name, dogs[0], f.avatar_url)}
<!-- Name + Infos + Hunde -->
<div class="flex-1-min">
<div style="display:flex;align-items:center;flex-wrap:wrap;gap:2px;
margin-bottom:var(--space-1)">
<span style="font-weight:var(--weight-semibold);color:var(--c-text)">
${_esc(f.friend_name)}
</span>
${_erfahrungSpan(f.erfahrung)}
</div>
${_wohnortLine(f.wohnort)}
${_bioLine(f.bio, f.profil_sichtbarkeit)}
<div style="margin-top:var(--space-1)">
${dogs.length
? `<div style="display:flex;flex-wrap:wrap;gap:var(--space-1);align-items:center">
${_dogPills(dogs, 3)}
</div>`
: `<div class="text-xs-muted">Noch kein Hund eingetragen</div>`
}
</div>
</div>
<!-- Aktionen -->
<div style="display:flex;gap:var(--space-1);flex-shrink:0">
<button class="btn btn-ghost btn-sm fr-note-btn"
data-fr-note-id="${f.friend_id}"
data-fr-note-name="${_esc(f.friend_name)}"
title="Notiz"
onclick="event.stopPropagation()">
<svg class="ph-icon"><use href="/icons/phosphor.svg#note-pencil"></use></svg>
</button>
<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 class="text-center">
<img src="${_esc(d.foto_url)}" alt="${_esc(d.name)}"
loading="lazy" decoding="async" onerror="this.style.display='none'"
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, profile = {}) {
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 class="text-center">
${d.foto_url
? `<img src="${_esc(d.foto_url)}" alt="${_esc(d.name)}"
loading="lazy" decoding="async" onerror="this.style.display='none'"
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 class="text-xs-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>`;
const profileInfoHTML = (() => {
const parts = [];
if (profile.wohnort) {
parts.push(`<div style="display:flex;align-items:center;gap:var(--space-2);
font-size:var(--text-sm);color:var(--c-text-secondary)">
📍 ${_esc(profile.wohnort)}
</div>`);
}
if (profile.erfahrung && _erfahrungBadge[profile.erfahrung]) {
parts.push(`<div class="text-sm-secondary">
${_erfahrungBadge[profile.erfahrung]}
</div>`);
}
if (profile.bio && profile.profil_sichtbarkeit !== 'private') {
parts.push(`<div style="font-size:var(--text-sm);color:var(--c-text);
line-height:1.5;padding-top:var(--space-2)">
${_esc(profile.bio)}
</div>`);
}
if (profile.social_link) {
parts.push(`<div style="font-size:var(--text-xs);word-break:break-all">
<a href="${_esc(profile.social_link)}" target="_blank" rel="noopener noreferrer"
class="text-primary">${_esc(profile.social_link)}</a>
</div>`);
}
if (!parts.length) return '';
return `<div style="display:flex;flex-direction:column;gap:var(--space-2);
margin-bottom:var(--space-4)">${parts.join('')}</div>`;
})();
const badgesHTML = (profile.is_founder || profile.is_partner) ? `
<div style="display:flex;gap:var(--space-2);margin-bottom:var(--space-3);flex-wrap:wrap">
${profile.is_founder ? `<span class="badge" style="background:#7c3aed;color:#fff">
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#key"></use></svg>
${profile.founder_number ? `Gründer #${profile.founder_number}` : 'Gründer'}
</span>` : ''}
${profile.is_partner ? `<span class="badge" style="background:#0ea5e9;color:#fff">
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#handshake"></use></svg>
Partner
</span>` : ''}
</div>` : '';
UI.modal.open({
title: _esc(friendName),
body: `
<div>
${badgesHTML}
${profileInfoHTML}
<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=""
class="text-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) {
if (!_appState.user) return;
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, u.avatar_url)}
<div class="flex-1-min">
<div style="display:flex;align-items:center;flex-wrap:wrap;gap:4px;
margin-bottom:2px">
<span style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text)">${_esc(u.name)}</span>
${u.is_founder ? `<span style="font-size:10px;font-weight:700;background:#7c3aed;color:#fff;padding:1px 5px;border-radius:4px">${u.founder_number ? `Gründer #${u.founder_number}` : 'Gründer'}</span>` : ''}
${u.is_partner ? `<span style="font-size:10px;font-weight:700;background:#0ea5e9;color:#fff;padding:1px 5px;border-radius:4px">Partner</span>` : ''}
${_erfahrungSpan(u.erfahrung)}
</div>
${_wohnortLine(u.wohnort)}
${_bioLine(u.bio, u.profil_sichtbarkeit)}
${u.dogs?.length
? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
margin-top:2px">
${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" title="Anfrage senden"
data-user-id="${u.id}" data-user-name="${_esc(u.name)}">
<svg class="ph-icon"><use href="/icons/phosphor.svg#user-plus"></use></svg>
</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) {
if (!_appState.user) { App.navigate('settings'); return; }
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) {
if (!_appState.user) { App.navigate('settings'); return; }
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, avatarUrl) {
if (avatarUrl) {
return `<img src="${_esc(avatarUrl)}" alt="${_esc(name)}"
loading="lazy" decoding="async" onerror="this.style.display='none'"
style="width:44px;height:44px;border-radius:50%;object-fit:cover;
border:2px solid var(--c-primary);flex-shrink:0">`;
}
if (firstDog?.foto_url) {
return `<img src="${_esc(firstDog.foto_url)}" alt="${_esc(firstDog.name)}"
loading="lazy" decoding="async" onerror="this.style.display='none'"
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>`;
}
const _erfahrungBadge = {
einsteiger: '🐾 Einsteiger',
erfahren: '⭐ Erfahren',
trainer: '🎓 Trainer',
zuechter: '🏅 Züchter',
};
function _erfahrungSpan(erfahrung) {
if (!erfahrung || !_erfahrungBadge[erfahrung]) return '';
return `<span style="font-size:10px;padding:1px 5px;border-radius:3px;
background:var(--c-surface-2);color:var(--c-text-secondary);
margin-left:4px;white-space:nowrap">${_erfahrungBadge[erfahrung]}</span>`;
}
function _wohnortLine(wohnort) {
if (!wohnort) return '';
return `<span class="text-xs-muted">📍 ${_esc(wohnort)}</span>`;
}
function _bioLine(bio, sichtbarkeit) {
if (!bio || sichtbarkeit === 'private') return '';
const text = bio.length > 120 ? bio.substring(0, 120) + '…' : bio;
return `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
margin-top:var(--space-1);line-height:1.4;
overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;
-webkit-box-orient:vertical">${_esc(text)}</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;');
}
function _emptyState(icon, title, text, cta = '') {
return `<div class="empty-state">
<svg class="ph-icon empty-state-icon" aria-hidden="true">
<use href="/icons/phosphor.svg#${icon}"></use>
</svg>
<div class="empty-state-title">${title}</div>
${text ? `<p class="empty-state-text">${text}</p>` : ''}
${cta ? `<div class="empty-state-cta">${cta}</div>` : ''}
</div>`;
}
// ----------------------------------------------------------
// NOTIZ-MODAL
// ----------------------------------------------------------
async function _openNoteModal(parentType, parentId, parentLabel, locationName) {
document.getElementById('by-note-modal')?.remove();
const overlay = document.createElement('div');
overlay.id = 'by-note-modal';
overlay.style.cssText = 'position:fixed;inset:0;z-index:2000;background:rgba(0,0,0,.55);display:flex;align-items:flex-end;justify-content:center';
overlay.innerHTML = `
<div style="background:var(--c-surface);border-radius:var(--radius-xl) var(--radius-xl) 0 0;
width:100%;max-width:640px;max-height:90vh;display:flex;flex-direction:column;
padding-bottom:env(safe-area-inset-bottom,0px)">
<div style="padding:var(--space-4) var(--space-5);border-bottom:1px solid var(--c-border);
display:flex;align-items:center;justify-content:space-between;flex-shrink:0">
<div>
<div style="font-weight:var(--weight-semibold);font-size:var(--text-base)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">${_esc(parentLabel)}</div>
</div>
<button id="by-note-close" style="background:none;border:none;cursor:pointer;font-size:1.4rem;color:var(--c-text-muted);padding:4px 8px" aria-label="Schließen">×</button>
</div>
<div style="padding:var(--space-4) var(--space-5);flex:1;overflow-y:auto">
<form id="by-note-form">
<textarea id="by-note-text" class="form-control" rows="5"
placeholder="Notiz eingeben…"
style="width:100%;resize:vertical"></textarea>
</form>
</div>
<div style="padding:var(--space-3) var(--space-5);border-top:1px solid var(--c-border);
display:flex;gap:var(--space-2);flex-shrink:0">
<button type="button" id="by-note-cancel" class="btn btn-secondary flex-1">Abbrechen</button>
<button type="submit" form="by-note-form" id="by-note-save" class="btn btn-primary flex-1">Speichern</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const textarea = document.getElementById('by-note-text');
const saveBtn = document.getElementById('by-note-save');
const cancelBtn = document.getElementById('by-note-cancel');
const closeBtn = document.getElementById('by-note-close');
let existingNoteId = null;
try {
const existing = await API.notes.get(parentType, String(parentId));
if (existing?.id) {
existingNoteId = existing.id;
textarea.value = existing.text || '';
}
} catch (_) { /* keine Notiz vorhanden — ok */ }
setTimeout(() => textarea.focus(), 100);
const _close = () => overlay.remove();
closeBtn.addEventListener('click', _close);
cancelBtn.addEventListener('click', _close);
overlay.addEventListener('click', e => { if (e.target === overlay) _close(); });
document.getElementById('by-note-form').addEventListener('submit', async e => {
e.preventDefault();
const text = textarea.value.trim();
UI.setLoading(saveBtn, true);
try {
const payload = { text, parent_label: parentLabel, location_name: locationName };
if (existingNoteId) {
await API.notes.update(existingNoteId, payload);
} else {
await API.notes.create(parentType, String(parentId), payload);
}
UI.toast.success('Notiz gespeichert.');
_close();
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Speichern.');
UI.setLoading(saveBtn, false);
}
});
}
// ----------------------------------------------------------
return { init, refresh, onDogChange, _accept, _decline, _cancel, _removeFriend, _openChat };
})();