banyaro/backend/static/js/pages/friends.js
rene 553e9e7854 Sprint 12+13: Tagebuch Day-One-Redesign, Notiz-Feature, Icon-Fixes, SW by-v405
Tagebuch:
- Day-One-Listenansicht: Wochentag + Tageszahl + Meta-Zeile (Zeit/Ort/Wetter)
- 4 Ansichten: Liste, Medien-Mosaik, Kalender (mit Sprungbuttons), Karte (GPS-Marker)
- Detail-Ansicht inline im Content-Bereich (kein Fullscreen-Overlay mehr)
- Hero-Bild vollständig sichtbar (object-fit:contain), Lightbox mit Safe-Area
- 2-Spalten-Layout Desktop: Text + Leaflet-Karte + POI-Liste
- EXIF-GPS-Extraktion bei Foto-Upload, historisches Wetter via Archive-API
- NoteStation-Import: Fotos in diary_media (80 Einträge migriert, 94 Medien)
- Stats-Endpoints: /diary/stats, /diary/calendar, /diary/locations

Notiz-Feature:
- Generische notes-Tabelle (parent_type + parent_id + meta_json)
- 📝-Button in 8 Bereichen, Notizblock-Seite mit KI-Analyse
- KI-Toggle in Einstellungen, notes_ki_enabled in User-Profil

Icons & Design:
- fill:currentColor Fix für welcome/onboarding/friends.js
- --c-icon Variable, --c-text-muted Dark Mode aufgehellt
- 15+ neue Phosphor-Icons aus lokaler Kopie
- CSS Network-First im SW, Cache-Control-Middleware

Infrastruktur:
- Wiki-Anreicherungs-Scheduler-Jobs entfernt (abgeschlossen)
- auth.py: notes_ki_enabled + is_social_media im User-Response
2026-04-25 20:44:46 +02:00

836 lines
33 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) {
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 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>
<!-- 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 || '')}"
class="fr-activity-avatar">`
: item.avatar_url
? `<img src="${_esc(item.avatar_url)}" alt="${_esc(item.user_name)}"
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 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 = _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>
`;
// 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 style="flex:1;min-width:0">
<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 style="font-size:var(--text-xs);color:var(--c-text-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"
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, 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 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>`;
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 style="font-size:var(--text-sm);color:var(--c-text-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"
style="color:var(--c-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>`;
})();
UI.modal.open({
title: _esc(friendName),
body: `
<div>
${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=""
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) {
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 style="flex:1;min-width:0">
<div style="display:flex;align-items:center;flex-wrap:wrap;gap:2px;
margin-bottom:2px">
<span style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text)">${_esc(u.name)}</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)}"
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)}"
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 style="font-size:var(--text-xs);color:var(--c-text-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>`;
}
// ----------------------------------------------------------
return { init, refresh, onDogChange, _accept, _decline, _cancel, _removeFriend, _openChat };
})();