Files
oikos/public/pages/contacts.js
T
Ulas 4fe4f6cb38 feat: BL-07–BL-10 — notes search, weather refresh, vCard import/export, PWA offline page
- Notes: client-side full-text search bar (filters title + content)
- Dashboard: weather refresh button + 30-min auto-refresh interval
- Contacts: vCard 3.0 export per contact (GET /:id/vcard); vCard import
  via file input with client-side parser (FN, TEL, EMAIL, ADR, NOTE, CATEGORIES)
- PWA: /offline.html served when network unavailable; cached in app-shell (sw v20)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 10:35:03 +02:00

393 lines
15 KiB
JavaScript

/**
* Modul: Kontakte (Contacts)
* Zweck: Kontaktliste mit Kategorie-Filter, Suche, CRUD, tel:/mailto:/maps-Links
* Abhängigkeiten: /api.js, /router.js (window.oikos)
*/
import { api } from '/api.js';
import { openModal as openSharedModal, closeModal } from '/components/modal.js';
import { stagger, vibrate } from '/utils/ux.js';
// --------------------------------------------------------
// Konstanten
// --------------------------------------------------------
const CATEGORIES = ['Arzt', 'Schule/Kita', 'Behörde', 'Versicherung',
'Handwerker', 'Notfall', 'Sonstiges'];
const CATEGORY_ICONS = {
'Arzt': '🏥',
'Schule/Kita': '🏫',
'Behörde': '🏛️',
'Versicherung': '🛡️',
'Handwerker': '🔧',
'Notfall': '🚨',
'Sonstiges': '📋',
};
// --------------------------------------------------------
// State
// --------------------------------------------------------
let state = {
contacts: [],
activeCategory: null,
searchQuery: '',
};
let _container = null;
// --------------------------------------------------------
// Entry Point
// --------------------------------------------------------
export async function render(container, { user }) {
_container = container;
container.innerHTML = `
<div class="contacts-page">
<h1 class="sr-only">Kontakte</h1>
<div class="contacts-toolbar">
<div class="contacts-toolbar__search">
<i data-lucide="search" class="contacts-toolbar__search-icon" aria-hidden="true"></i>
<input type="search" class="contacts-toolbar__search-input"
id="contacts-search" placeholder="Name, Telefon oder E-Mail suchen…"
autocomplete="off">
</div>
<label class="btn btn--secondary" title="vCard importieren" aria-label="Kontakt aus vCard importieren">
<i data-lucide="upload" style="width:16px;height:16px;margin-right:4px;" aria-hidden="true"></i>
Import
<input type="file" id="contacts-import-input" accept=".vcf,text/vcard" style="display:none">
</label>
<button class="btn btn--primary" id="contacts-add-btn">
<i data-lucide="plus" style="width:16px;height:16px;margin-right:4px;" aria-hidden="true"></i>
Neu
</button>
</div>
<div class="contacts-filters" id="contacts-filters">
<button class="contact-filter-chip contact-filter-chip--active" data-cat="">Alle</button>
${CATEGORIES.map((c) => `
<button class="contact-filter-chip" data-cat="${escHtml(c)}">${CATEGORY_ICONS[c] || ''} ${escHtml(c)}</button>
`).join('')}
</div>
<div id="contacts-list" class="contacts-list"></div>
<button class="page-fab" id="fab-new-contact" aria-label="Neuer Kontakt">
<i data-lucide="plus" style="width:24px;height:24px" aria-hidden="true"></i>
</button>
</div>
`;
if (window.lucide) lucide.createIcons();
const res = await api.get('/contacts');
state.contacts = res.data;
renderList();
// Suche
let searchTimer;
_container.querySelector('#contacts-search').addEventListener('input', (e) => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
state.searchQuery = e.target.value.trim();
renderList();
}, 200);
});
// Kategorie-Filter
_container.querySelector('#contacts-filters').addEventListener('click', (e) => {
const chip = e.target.closest('[data-cat]');
if (!chip) return;
_container.querySelectorAll('.contact-filter-chip').forEach((c) =>
c.classList.toggle('contact-filter-chip--active', c === chip)
);
state.activeCategory = chip.dataset.cat || null;
renderList();
});
// Neu
const addHandler = () => openContactModal({ mode: 'create' });
_container.querySelector('#contacts-add-btn').addEventListener('click', addHandler);
_container.querySelector('#fab-new-contact').addEventListener('click', addHandler);
// vCard-Import
_container.querySelector('#contacts-import-input').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
e.target.value = '';
try {
const text = await file.text();
const contact = parseVCard(text);
if (!contact.name) { window.oikos?.showToast('vCard enthält keinen Namen.', 'warning'); return; }
const res = await api.post('/contacts', contact);
state.contacts.push(res.data);
renderList();
window.oikos?.showToast(`${res.data.name} importiert.`, 'success');
} catch (err) {
window.oikos?.showToast('Import fehlgeschlagen: ' + err.message, 'danger');
}
});
}
// --------------------------------------------------------
// Liste rendern
// --------------------------------------------------------
function filterContacts() {
let list = state.contacts;
if (state.activeCategory) {
list = list.filter((c) => c.category === state.activeCategory);
}
if (state.searchQuery) {
const q = state.searchQuery.toLowerCase();
list = list.filter((c) =>
c.name.toLowerCase().includes(q) ||
(c.phone && c.phone.toLowerCase().includes(q)) ||
(c.email && c.email.toLowerCase().includes(q))
);
}
return list;
}
function renderList() {
const container = _container.querySelector('#contacts-list');
if (!container) return;
const contacts = filterContacts();
if (!contacts.length) {
container.innerHTML = `
<div class="empty-state">
<svg class="empty-state__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
<div class="empty-state__title">Noch keine Kontakte</div>
<div class="empty-state__description">Neue Kontakte über den + Button hinzufügen.</div>
</div>
`;
if (window.lucide) lucide.createIcons();
return;
}
// Nach Kategorie gruppieren
const groups = {};
for (const c of contacts) {
if (!groups[c.category]) groups[c.category] = [];
groups[c.category].push(c);
}
container.innerHTML = Object.entries(groups)
.sort(([a], [b]) => CATEGORIES.indexOf(a) - CATEGORIES.indexOf(b))
.map(([cat, items]) => `
<div class="contact-group">
<div class="contact-group__header">${CATEGORY_ICONS[cat] || ''} ${escHtml(cat)}</div>
${items.map((c) => renderContactItem(c)).join('')}
</div>
`).join('');
if (window.lucide) lucide.createIcons();
stagger(container.querySelectorAll('.contact-item'));
// Event-Delegation
container.addEventListener('click', async (e) => {
if (e.target.closest('[data-action="delete"]')) {
const id = parseInt(e.target.closest('[data-action="delete"]').dataset.id, 10);
await deleteContact(id);
return;
}
const item = e.target.closest('.contact-item[data-id]');
if (item && !e.target.closest('a') && !e.target.closest('[data-action]')) {
const c = state.contacts.find((c) => c.id === parseInt(item.dataset.id, 10));
if (c) openContactModal({ mode: 'edit', contact: c });
}
});
}
function renderContactItem(c) {
const phone = c.phone ? `<a href="tel:${escHtml(c.phone)}" class="contact-action-btn contact-action-btn--call" aria-label="Anrufen"><i data-lucide="phone" style="width:16px;height:16px;" aria-hidden="true"></i></a>` : '';
const email = c.email ? `<a href="mailto:${escHtml(c.email)}" class="contact-action-btn contact-action-btn--mail" aria-label="E-Mail"><i data-lucide="mail" style="width:16px;height:16px;" aria-hidden="true"></i></a>` : '';
const maps = c.address ? `<a href="https://maps.google.com/?q=${encodeURIComponent(c.address)}" target="_blank" rel="noopener" class="contact-action-btn contact-action-btn--maps" aria-label="In Maps öffnen"><i data-lucide="map-pin" style="width:16px;height:16px;" aria-hidden="true"></i></a>` : '';
const meta = [c.phone, c.email].filter(Boolean).join(' · ');
return `
<div class="contact-item" data-id="${c.id}">
<div class="contact-item__icon">${CATEGORY_ICONS[c.category] || '📋'}</div>
<div class="contact-item__body">
<div class="contact-item__name">${escHtml(c.name)}</div>
${meta ? `<div class="contact-item__meta">${escHtml(meta)}</div>` : ''}
</div>
<div class="contact-item__actions">
${phone}${email}${maps}
<a href="/api/v1/contacts/${c.id}/vcard" download="${escHtml(c.name)}.vcf"
class="contact-action-btn" aria-label="Als vCard exportieren" title="vCard exportieren">
<i data-lucide="download" style="width:16px;height:16px;" aria-hidden="true"></i>
</a>
<button class="contact-action-btn" data-action="delete" data-id="${c.id}" aria-label="Kontakt löschen">
<i data-lucide="trash-2" style="width:16px;height:16px;" aria-hidden="true"></i>
</button>
</div>
</div>
`;
}
// --------------------------------------------------------
// Modal
// --------------------------------------------------------
function openContactModal({ mode, contact = null }) {
const isEdit = mode === 'edit';
const v = (field) => escHtml(isEdit && contact[field] ? contact[field] : '');
const catOpts = CATEGORIES.map((c) =>
`<option value="${c}" ${isEdit && contact.category === c ? 'selected' : ''}>${c}</option>`
).join('');
const content = `
<div class="form-group">
<label class="form-label" for="cm-name">Name *</label>
<input type="text" class="form-input" id="cm-name" placeholder="Vollständiger Name" value="${v('name')}">
</div>
<div class="form-group">
<label class="form-label" for="cm-category">Kategorie</label>
<select class="form-input" id="cm-category">${catOpts}</select>
</div>
<div class="form-group">
<label class="form-label" for="cm-phone">Telefon</label>
<input type="tel" class="form-input" id="cm-phone" placeholder="+49 …" value="${v('phone')}">
</div>
<div class="form-group">
<label class="form-label" for="cm-email">E-Mail</label>
<input type="email" class="form-input" id="cm-email" placeholder="name@beispiel.de" value="${v('email')}">
</div>
<div class="form-group">
<label class="form-label" for="cm-address">Adresse</label>
<input type="text" class="form-input" id="cm-address" placeholder="Straße, PLZ Ort" value="${v('address')}">
</div>
<div class="form-group">
<label class="form-label" for="cm-notes">Notizen</label>
<textarea class="form-input" id="cm-notes" rows="2" placeholder="Optional…">${v('notes')}</textarea>
</div>
<div class="modal-panel__footer" style="border:none;padding:0;margin-top:var(--space-4)">
${isEdit ? `<button class="btn btn--danger btn--icon" id="cm-delete" aria-label="Kontakt löschen">
<i data-lucide="trash-2" style="width:16px;height:16px;" aria-hidden="true"></i>
</button>` : '<div></div>'}
<div style="display:flex;gap:var(--space-3);">
<button class="btn btn--secondary" id="cm-cancel">Abbrechen</button>
<button class="btn btn--primary" id="cm-save">${isEdit ? 'Speichern' : 'Erstellen'}</button>
</div>
</div>`;
openSharedModal({
title: isEdit ? 'Kontakt bearbeiten' : 'Neuer Kontakt',
content,
size: 'md',
onSave(panel) {
panel.querySelector('#cm-cancel').addEventListener('click', closeModal);
panel.querySelector('#cm-delete')?.addEventListener('click', async () => {
if (!confirm(`"${contact.name}" wirklich löschen?`)) return;
closeModal();
await deleteContact(contact.id);
});
panel.querySelector('#cm-save').addEventListener('click', async () => {
const saveBtn = panel.querySelector('#cm-save');
const name = panel.querySelector('#cm-name').value.trim();
const category = panel.querySelector('#cm-category').value;
const phone = panel.querySelector('#cm-phone').value.trim() || null;
const email = panel.querySelector('#cm-email').value.trim() || null;
const address = panel.querySelector('#cm-address').value.trim() || null;
const notes = panel.querySelector('#cm-notes').value.trim() || null;
if (!name) { window.oikos?.showToast('Name ist erforderlich', 'error'); return; }
saveBtn.disabled = true;
saveBtn.textContent = '…';
try {
const body = { name, category, phone, email, address, notes };
if (mode === 'create') {
const res = await api.post('/contacts', body);
state.contacts.push(res.data);
state.contacts.sort((a, b) =>
CATEGORIES.indexOf(a.category) - CATEGORIES.indexOf(b.category) ||
a.name.localeCompare(b.name)
);
} else {
const res = await api.put(`/contacts/${contact.id}`, body);
const idx = state.contacts.findIndex((c) => c.id === contact.id);
if (idx !== -1) state.contacts[idx] = res.data;
}
closeModal();
renderList();
window.oikos?.showToast(mode === 'create' ? 'Kontakt gespeichert' : 'Kontakt aktualisiert', 'success');
} catch (err) {
window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error');
saveBtn.disabled = false;
saveBtn.textContent = isEdit ? 'Speichern' : 'Erstellen';
}
});
},
});
}
async function deleteContact(id) {
if (!confirm('Kontakt wirklich löschen?')) return;
try {
await api.delete(`/contacts/${id}`);
state.contacts = state.contacts.filter((c) => c.id !== id);
renderList();
vibrate([30, 50, 30]);
window.oikos?.showToast('Kontakt gelöscht', 'success');
} catch (err) {
window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error');
}
}
function escHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/**
* Minimaler vCard 3.0/4.0 Parser.
* Gibt { name, phone, email, address, notes, category } zurück.
*/
function parseVCard(text) {
const unescapeVCard = (s) => String(s || '')
.replace(/\\n/g, '\n').replace(/\\,/g, ',').replace(/\\;/g, ';').replace(/\\\\/g, '\\');
// Zeilenfortsetzungen entfalten (RFC 6350 §3.2)
const unfolded = text.replace(/\r?\n[ \t]/g, '');
const get = (prop) => {
const re = new RegExp(`^${prop}(?:;[^:]*)?:(.*)$`, 'im');
const m = re.exec(unfolded);
return m ? unescapeVCard(m[1].trim()) : null;
};
const name = get('FN') || get('N')?.split(';')[0] || null;
const phone = get('TEL') || null;
const email = get('EMAIL') || null;
// ADR: ;;street;city;region;postal;country
const adrRaw = get('ADR');
let address = null;
if (adrRaw) {
const parts = adrRaw.split(';').map((p) => p.trim()).filter(Boolean);
address = parts.join(', ') || null;
}
const notes = get('NOTE') || null;
const catRaw = get('CATEGORIES') || null;
const category = CATEGORIES.find((c) => catRaw?.toLowerCase().includes(c.toLowerCase())) || 'Sonstiges';
return { name, phone, email, address, notes, category };
}