banyaro/backend/static/js/pages/friends.js
rene 459cd425f2 Design-System Sprint A: utilities.css + 948 Inline-Styles → Utility-Klassen, SW by-v1102
PHASE 1 — Sofort-Cleanup ohne Risiko:
- Neue Datei utilities.css mit ~25 Klassen für häufige Kombinationen:
  * text-xs-muted, text-xs-secondary, text-sm-muted, text-sm-secondary
  * flex-gap-2/3, flex-col-gap-2/3/4, flex-center-gap-1/2/3
  * flex-between, flex-1-min, mb-1/3, mt-1/3
  * icon-xs/sm/md/lg, label-block, caption
- index.html bindet utilities.css ein
- mb-3/mt-3 ergänzt (waren in design-system.css unvollständig)

PHASE 2 — .by-tab Modifier für Vereinheitlichung:
- .by-tabs.grid (mit --tab-cols Variable für Admin/Health/etc.)
- .by-tabs.sticky (Desktop vertikale Tabs für Admin)
- .by-tabs.wrap (Zuchthunde, flex-wrap statt scroll)
- .by-tabs.separated (Sitting, mit eigenem Hintergrund + Border)

PHASE 3 — Inline-Style → Klassen-Migration (Python-Script):
- 948 Inline-Styles entfernt (5101 → 4153, -18%)
- 962 Migrationen über 47 Page-Dateien
- Top-Treffer: admin.js (180), health.js (67), dog-profile.js (67),
  litters.js (62), settings.js (61), zuchthunde.js (51)
- Patterns: text-muted, text-secondary, text-danger, text-xs-muted,
  text-sm-muted, grid-2 (Duplikat-Bug behoben!), flex-col-gap-3,
  p-3/4, mb-2/3/4, hidden, w-full, flex-1, ...
- Bewahrt bestehende class-Attribute (mergt korrekt)

Alle 19 Tests grün. Kein visueller Diff erwartet (gleiche Property-Werte).
2026-05-27 07:11:27 +02:00

949 lines
38 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 || '')}"
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 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)}"
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)}"
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)}"
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 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 };
})();