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).
949 lines
38 KiB
JavaScript
949 lines
38 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 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(' | ')}
|
||
</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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
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 };
|
||
|
||
})();
|