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).
521 lines
19 KiB
JavaScript
521 lines
19 KiB
JavaScript
/* ============================================================
|
|
BAN YARO — Chat-Seite
|
|
============================================================ */
|
|
|
|
window.Page_chat = (() => {
|
|
|
|
let _container = null;
|
|
let _view = 'list'; // 'list' | 'thread'
|
|
let _convId = null;
|
|
let _partnerName = '';
|
|
let _myId = null;
|
|
let _pollTimer = null;
|
|
let _heartbeatTimer = null;
|
|
let _lastMsgId = 0;
|
|
|
|
// ----------------------------------------------------------
|
|
async function init(container, appState, params = {}) {
|
|
_container = container;
|
|
_myId = appState?.user?.id || null;
|
|
|
|
// Heartbeat: alle 30s online-Status senden
|
|
API.chat.heartbeat().catch(() => {});
|
|
_heartbeatTimer = setInterval(() => {
|
|
API.chat.heartbeat().catch(() => {});
|
|
}, 30000);
|
|
|
|
if (params.conversation_id) {
|
|
await _openThread(params.conversation_id);
|
|
} else {
|
|
await _showList();
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// Conversation list
|
|
// ----------------------------------------------------------
|
|
const _isDesktop = () => window.innerWidth >= 768;
|
|
|
|
function _listPaneHTML() {
|
|
return `
|
|
<div style="display:flex;align-items:center;justify-content:space-between;
|
|
padding:0 var(--space-4);height:56px;box-sizing:border-box;
|
|
flex-shrink:0;border-bottom:1px solid var(--c-border);
|
|
background:var(--c-surface)">
|
|
<h2 style="font-size:var(--text-base);font-weight:var(--weight-bold);margin:0">Nachrichten</h2>
|
|
<button class="btn btn-primary btn-sm" id="chat-new-btn">
|
|
${UI.icon('pencil-simple')} Neue Nachricht
|
|
</button>
|
|
</div>
|
|
<div id="chat-list-body" style="overflow-y:auto;flex:1"></div>`;
|
|
}
|
|
|
|
async function _showList() {
|
|
_view = 'list';
|
|
_stopPolling();
|
|
_convId = null;
|
|
|
|
if (_isDesktop()) {
|
|
// Split-Pane: linke Spalte bleibt, rechte zeigt Placeholder
|
|
if (!document.getElementById('chat-split')) {
|
|
_container.innerHTML = `
|
|
<div id="chat-split" style="display:flex;flex:1;min-height:0;overflow:hidden;width:100%">
|
|
<div id="chat-list-pane" style="width:320px;flex-shrink:0;display:flex;
|
|
flex-direction:column;border-right:1px solid var(--c-border);
|
|
background:var(--c-surface);min-height:0">
|
|
${_listPaneHTML()}
|
|
</div>
|
|
<div id="chat-thread-pane" style="flex:1;min-width:0;display:flex;
|
|
align-items:center;justify-content:center;
|
|
background:var(--c-bg);color:var(--c-text-muted);font-size:var(--text-sm)">
|
|
${UI.icon('chat-circle-dots')} Gespräch auswählen
|
|
</div>
|
|
</div>`;
|
|
document.getElementById('chat-new-btn')?.addEventListener('click', _showNewMessagePicker);
|
|
} else {
|
|
// Split existiert — nur rechte Seite zurücksetzen
|
|
const pane = document.getElementById('chat-thread-pane');
|
|
if (pane) {
|
|
pane.style.cssText = 'flex:1;display:flex;align-items:center;justify-content:center;background:var(--c-bg);color:var(--c-text-muted);font-size:var(--text-sm)';
|
|
pane.innerHTML = `${UI.icon('chat-circle-dots')} Gespräch auswählen`;
|
|
}
|
|
}
|
|
} else {
|
|
_container.innerHTML = `
|
|
<div style="background:var(--c-surface)">
|
|
<div style="display:flex;align-items:center;justify-content:space-between;
|
|
padding:var(--space-4) var(--space-4) var(--space-2)">
|
|
<h2 style="font-size:var(--text-xl);font-weight:var(--weight-bold);margin:0">Nachrichten</h2>
|
|
<button class="btn btn-primary btn-sm" id="chat-new-btn">
|
|
${UI.icon('pencil-simple')} Neue Nachricht
|
|
</button>
|
|
</div>
|
|
<div id="chat-list-body"></div>
|
|
</div>`;
|
|
document.getElementById('chat-new-btn')?.addEventListener('click', _showNewMessagePicker);
|
|
}
|
|
|
|
await _loadList();
|
|
await _updateChatBadge();
|
|
}
|
|
|
|
async function _loadList() {
|
|
const el = document.getElementById('chat-list-body');
|
|
if (!el) return;
|
|
try {
|
|
const convs = await API.chat.conversations();
|
|
if (!convs.length) {
|
|
el.innerHTML = `
|
|
<div class="empty-state" style="padding:var(--space-12) var(--space-4)">
|
|
<svg class="ph-icon" style="font-size:3rem;opacity:0.3">
|
|
<use href="/icons/phosphor.svg#chat-circle-dots"></use>
|
|
</svg>
|
|
<p style="margin-top:var(--space-3);color:var(--c-text-muted)">
|
|
Noch keine Nachrichten.<br>
|
|
Schreibe einem Freund über die Freunde-Seite!
|
|
</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
el.innerHTML = convs.map(c => {
|
|
const initials = (c.partner_name || '?')[0].toUpperCase();
|
|
const preview = c.last_text
|
|
? _esc(c.last_text.substring(0, 60)) + (c.last_text.length > 60 ? '…' : '')
|
|
: '<em style="opacity:0.6">Noch keine Nachrichten</em>';
|
|
const timeStr = c.last_msg_at ? _fmtTime(c.last_msg_at) : '';
|
|
const badge = c.unread_count > 0
|
|
? `<span class="chat-unread-badge">${c.unread_count}</span>`
|
|
: '';
|
|
const onlineDot = c.partner_online
|
|
? `<span class="online-dot" title="Online"></span>`
|
|
: '';
|
|
return `
|
|
<div class="chat-conv-item" onclick="Page_chat._openThread(${c.id})">
|
|
<div style="position:relative;flex-shrink:0">
|
|
<div class="chat-conv-avatar">${initials}</div>
|
|
${onlineDot ? `<span class="online-dot chat-avatar-dot"></span>` : ''}
|
|
</div>
|
|
<div class="chat-conv-info">
|
|
<div class="chat-conv-name">${_esc(c.partner_name)}</div>
|
|
<div class="chat-conv-preview">${preview}</div>
|
|
</div>
|
|
<div class="chat-conv-meta">
|
|
<span class="chat-conv-time">${timeStr}</span>
|
|
${badge}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
} catch (e) {
|
|
if (e.status === 401) {
|
|
document.getElementById('chat-list-body').innerHTML =
|
|
`<div class="empty-state"><p>Bitte melde dich an.</p></div>`;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// Thread (message view)
|
|
// ----------------------------------------------------------
|
|
async function _openThread(convId) {
|
|
_convId = convId;
|
|
_view = 'thread';
|
|
_stopPolling();
|
|
|
|
// Aktive Markierung in der Liste
|
|
document.querySelectorAll('.chat-conv-item').forEach(el =>
|
|
el.classList.toggle('active', el.getAttribute('onclick')?.includes(String(convId)))
|
|
);
|
|
|
|
const threadHTML = `
|
|
<div class="chat-thread" id="chat-thread">
|
|
<div class="chat-thread-header">
|
|
${_isDesktop() ? '' : `
|
|
<button class="btn btn-ghost btn-sm" onclick="Page_chat._showList()" style="padding:var(--space-1)">
|
|
<svg class="ph-icon"><use href="/icons/phosphor.svg#arrow-left"></use></svg>
|
|
</button>`}
|
|
<div style="position:relative;flex-shrink:0">
|
|
<div class="chat-conv-avatar" id="chat-partner-av" style="width:32px;height:32px;font-size:var(--text-sm)">?</div>
|
|
<span class="online-dot chat-avatar-dot" id="chat-partner-dot" class="hidden"></span>
|
|
</div>
|
|
<span class="chat-thread-partner" id="chat-partner-name">…</span>
|
|
</div>
|
|
<div class="chat-messages" id="chat-messages">
|
|
<div style="text-align:center;padding:var(--space-6);color:var(--c-text-muted);font-size:var(--text-sm)">
|
|
Laden…
|
|
</div>
|
|
</div>
|
|
<div class="chat-input-bar">
|
|
<input type="file" id="chat-photo-input" accept="image/*" class="hidden"
|
|
onchange="Page_chat._onPhotoSelected(this)">
|
|
<button class="chat-photo-btn" onclick="document.getElementById('chat-photo-input').click()" title="Foto senden">
|
|
<svg class="ph-icon"><use href="/icons/phosphor.svg#camera"></use></svg>
|
|
</button>
|
|
<textarea id="chat-input" class="chat-input" rows="1"
|
|
placeholder="Nachricht…" maxlength="2000"></textarea>
|
|
<button class="chat-send-btn" id="chat-send-btn" onclick="Page_chat._send()">
|
|
<svg class="ph-icon"><use href="/icons/phosphor.svg#paper-plane-tilt"></use></svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const threadPane = document.getElementById('chat-thread-pane');
|
|
if (_isDesktop() && threadPane) {
|
|
threadPane.style.cssText = 'flex:1;min-width:0;display:flex;flex-direction:column';
|
|
threadPane.innerHTML = threadHTML;
|
|
} else {
|
|
_container.innerHTML = threadHTML;
|
|
}
|
|
|
|
// Auto-resize textarea
|
|
const input = document.getElementById('chat-input');
|
|
input.addEventListener('input', () => {
|
|
input.style.height = 'auto';
|
|
input.style.height = Math.min(input.scrollHeight, 100) + 'px';
|
|
});
|
|
input.addEventListener('keydown', e => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
Page_chat._send();
|
|
}
|
|
});
|
|
|
|
await _loadMessages(true);
|
|
await API.chat.markRead(_convId).catch(() => {});
|
|
await _updateChatBadge();
|
|
|
|
// Poll every 4s while thread is open
|
|
_pollTimer = setInterval(async () => {
|
|
if (_view === 'thread' && _convId === convId) {
|
|
await _pollNew();
|
|
}
|
|
}, 4000);
|
|
}
|
|
|
|
async function _loadMessages(scroll = false) {
|
|
const el = document.getElementById('chat-messages');
|
|
if (!el) return;
|
|
try {
|
|
const data = await API.chat.messages(_convId);
|
|
_partnerName = data.partner_name;
|
|
|
|
const nameEl = document.getElementById('chat-partner-name');
|
|
const avEl = document.getElementById('chat-partner-av');
|
|
const dotEl = document.getElementById('chat-partner-dot');
|
|
if (nameEl) nameEl.textContent = data.partner_name;
|
|
if (avEl) avEl.textContent = (data.partner_name || '?')[0].toUpperCase();
|
|
if (dotEl) dotEl.style.display = data.partner_online ? '' : 'none';
|
|
|
|
if (!data.messages.length) {
|
|
el.innerHTML = `
|
|
<div style="text-align:center;padding:var(--space-8);color:var(--c-text-muted);font-size:var(--text-sm)">
|
|
Schreibe die erste Nachricht!
|
|
</div>
|
|
`;
|
|
_lastMsgId = 0;
|
|
return;
|
|
}
|
|
|
|
_lastMsgId = data.messages[data.messages.length - 1].id;
|
|
el.innerHTML = _renderMessages(data.messages);
|
|
|
|
if (scroll) _scrollToBottom(el);
|
|
} catch (e) {
|
|
if (el) el.innerHTML = `<div class="empty-state"><p>Fehler beim Laden.</p></div>`;
|
|
}
|
|
}
|
|
|
|
async function _pollNew() {
|
|
const el = document.getElementById('chat-messages');
|
|
if (!el) return;
|
|
try {
|
|
const data = await API.chat.messages(_convId);
|
|
if (!data.messages.length) return;
|
|
|
|
const newest = data.messages[data.messages.length - 1];
|
|
if (newest.id <= _lastMsgId) return;
|
|
|
|
// New messages arrived
|
|
_lastMsgId = newest.id;
|
|
const wasAtBottom = _isScrolledToBottom(el);
|
|
el.innerHTML = _renderMessages(data.messages);
|
|
if (wasAtBottom) _scrollToBottom(el);
|
|
|
|
// Online-Dot aktualisieren
|
|
const dotEl = document.getElementById('chat-partner-dot');
|
|
if (dotEl) dotEl.style.display = data.partner_online ? '' : 'none';
|
|
|
|
await API.chat.markRead(_convId).catch(() => {});
|
|
await _updateChatBadge();
|
|
} catch (e) {
|
|
// silent
|
|
}
|
|
}
|
|
|
|
function _renderMessages(msgs) {
|
|
let html = '';
|
|
let lastDate = null;
|
|
|
|
for (const m of msgs) {
|
|
const dateStr = m.created_at.substring(0, 10);
|
|
if (dateStr !== lastDate) {
|
|
html += `<div class="chat-date-divider">${_fmtDate(m.created_at)}</div>`;
|
|
lastDate = dateStr;
|
|
}
|
|
|
|
const isMine = m.sender_id === _myId;
|
|
const rowClass = isMine ? 'chat-bubble-row--mine' : 'chat-bubble-row--theirs';
|
|
const bubClass = isMine ? 'chat-bubble--mine' : 'chat-bubble--theirs';
|
|
const delClass = m.is_deleted ? ' chat-bubble--deleted' : '';
|
|
const timeStr = _fmtTime(m.created_at);
|
|
const deleteBtn = isMine && !m.is_deleted
|
|
? `<button class="btn btn-ghost" style="padding:2px;opacity:0.4;font-size:var(--text-xs)"
|
|
onclick="Page_chat._deleteMsg(${m.id})" title="Löschen">
|
|
<svg class="ph-icon" style="width:12px;height:12px"><use href="/icons/phosphor.svg#trash"></use></svg>
|
|
</button>`
|
|
: '';
|
|
|
|
// Read receipt icon (nur für eigene Nachrichten)
|
|
const readIcon = isMine
|
|
? (m.read_at
|
|
? `<svg class="ph-icon" style="width:12px;height:12px;color:var(--c-primary)"><use href="/icons/phosphor.svg#checks"></use></svg>`
|
|
: `<svg class="ph-icon" style="width:12px;height:12px;opacity:0.5"><use href="/icons/phosphor.svg#check"></use></svg>`)
|
|
: '';
|
|
|
|
// Medieninhalt
|
|
let bubbleContent = '';
|
|
if (m.media_url) {
|
|
bubbleContent += `<img src="${UI.escape(m.media_url)}" class="chat-bubble-img" alt="Foto" onclick="window.open('${UI.escape(m.media_url)}','_blank')">`;
|
|
}
|
|
if (m.text) {
|
|
bubbleContent += (m.media_url ? `<div style="margin-top:var(--space-1)">` : '') +
|
|
_esc(m.text) +
|
|
(m.media_url ? `</div>` : '');
|
|
}
|
|
if (!bubbleContent) bubbleContent = _esc(m.text);
|
|
|
|
html += `
|
|
<div class="chat-bubble-row ${rowClass}">
|
|
<div>
|
|
<div class="chat-bubble ${bubClass}${delClass}">${bubbleContent}</div>
|
|
<div class="chat-bubble-time" style="display:flex;align-items:center;gap:2px;justify-content:${isMine ? 'flex-end' : 'flex-start'}">
|
|
${timeStr} ${readIcon} ${deleteBtn}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
return html;
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
async function _send() {
|
|
const input = document.getElementById('chat-input');
|
|
const btn = document.getElementById('chat-send-btn');
|
|
if (!input) return;
|
|
const text = input.value.trim();
|
|
if (!text || !_convId) return;
|
|
|
|
btn.disabled = true;
|
|
try {
|
|
await API.chat.send(_convId, text);
|
|
input.value = '';
|
|
input.style.height = 'auto';
|
|
await _loadMessages(true);
|
|
} catch (e) {
|
|
UI.toast(e.message, 'danger');
|
|
} finally {
|
|
btn.disabled = false;
|
|
input.focus();
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
async function _deleteMsg(msgId) {
|
|
try {
|
|
await API.chat.deleteMessage(msgId);
|
|
await _loadMessages(false);
|
|
} catch (e) {
|
|
UI.toast(e.message, 'danger');
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
async function _onPhotoSelected(input) {
|
|
const file = input.files && input.files[0];
|
|
if (!file || !_convId) return;
|
|
input.value = '';
|
|
|
|
const btn = document.getElementById('chat-send-btn');
|
|
if (btn) btn.disabled = true;
|
|
try {
|
|
await API.chat.uploadPhoto(_convId, file);
|
|
await _loadMessages(true);
|
|
} catch (e) {
|
|
UI.toast(e.message || 'Foto-Upload fehlgeschlagen', 'danger');
|
|
} finally {
|
|
if (btn) btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
async function _updateChatBadge() {
|
|
try {
|
|
const convs = await API.chat.conversations();
|
|
const total = convs.reduce((s, c) => s + (c.unread_count || 0), 0);
|
|
const badge = document.getElementById('chat-badge');
|
|
if (badge) {
|
|
badge.textContent = total;
|
|
badge.style.display = total > 0 ? '' : 'none';
|
|
}
|
|
} catch (e) {
|
|
// silent
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// Helpers
|
|
// ----------------------------------------------------------
|
|
function _stopPolling() {
|
|
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; }
|
|
}
|
|
|
|
function _scrollToBottom(el) {
|
|
el.scrollTop = el.scrollHeight;
|
|
}
|
|
|
|
function _isScrolledToBottom(el) {
|
|
return el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
|
}
|
|
|
|
function _fmtTime(iso) {
|
|
if (!iso) return '';
|
|
const d = new Date(iso.replace(' ', 'T') + 'Z');
|
|
const now = new Date();
|
|
const isToday = d.toDateString() === now.toDateString();
|
|
if (isToday) return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
|
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
|
|
}
|
|
|
|
function _fmtDate(iso) {
|
|
if (!iso) return '';
|
|
const d = new Date(iso.replace(' ', 'T') + 'Z');
|
|
const now = new Date();
|
|
const diff = Math.floor((now - d) / 86400000);
|
|
if (diff === 0) return 'Heute';
|
|
if (diff === 1) return 'Gestern';
|
|
return d.toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' });
|
|
}
|
|
|
|
function _esc(s) {
|
|
if (!s) return '';
|
|
return String(s)
|
|
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')
|
|
.replace(/\n/g, '<br>');
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// Neue Nachricht — Freundesliste als Picker
|
|
// ----------------------------------------------------------
|
|
async function _showNewMessagePicker() {
|
|
let friends = [];
|
|
try { friends = (await API.friends.list()).friends || []; } catch {}
|
|
|
|
if (!friends.length) {
|
|
UI.toast.info('Du hast noch keine Freunde. Gehe zu Freunde um jemanden hinzuzufügen.');
|
|
return;
|
|
}
|
|
|
|
const items = friends.map(f => `
|
|
<button class="btn btn-ghost" data-uid="${f.friend_id}" style="
|
|
display:flex;align-items:center;gap:var(--space-3);
|
|
width:100%;text-align:left;padding:var(--space-3)">
|
|
<div style="width:40px;height:40px;border-radius:50%;
|
|
background:var(--c-primary-subtle);display:flex;align-items:center;
|
|
justify-content:center;font-weight:600;flex-shrink:0">
|
|
${f.avatar_url
|
|
? `<img src="${UI.escape(f.avatar_url)}" style="width:40px;height:40px;border-radius:50%;object-fit:cover">`
|
|
: UI.escape((f.friend_name||'?')[0].toUpperCase())}
|
|
</div>
|
|
<span>${UI.escape(f.friend_name || '—')}</span>
|
|
</button>`).join('');
|
|
|
|
UI.modal.open({
|
|
title: 'Neue Nachricht',
|
|
body: `<div style="display:flex;flex-direction:column;gap:2px">${items}</div>`,
|
|
footer: `<button class="btn btn-ghost flex-1" id="chat-picker-cancel">Abbrechen</button>`,
|
|
});
|
|
|
|
document.getElementById('chat-picker-cancel')?.addEventListener('click', UI.modal.close);
|
|
document.querySelectorAll('[data-uid]').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
UI.modal.close();
|
|
const uid = parseInt(btn.dataset.uid);
|
|
try {
|
|
const { conversation_id } = await API.chat.start(uid);
|
|
await _openThread(conversation_id);
|
|
} catch (e) { UI.toast.error(e.message); }
|
|
});
|
|
});
|
|
}
|
|
|
|
async function refresh() {
|
|
await _loadList();
|
|
await _updateChatBadge();
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
return {
|
|
init,
|
|
refresh,
|
|
_showList,
|
|
_openThread,
|
|
_send,
|
|
_deleteMsg,
|
|
_onPhotoSelected,
|
|
};
|
|
|
|
})();
|