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>
This commit is contained in:
@@ -52,6 +52,11 @@ export async function render(container, { user }) {
|
||||
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
|
||||
@@ -101,6 +106,24 @@ export async function render(container, { user }) {
|
||||
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');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
@@ -198,6 +221,10 @@ function renderContactItem(c) {
|
||||
</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>
|
||||
@@ -327,3 +354,39 @@ function escHtml(str) {
|
||||
.replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
|
||||
@@ -289,7 +289,10 @@ function renderWeatherWidget(weather) {
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="widget weather-widget">
|
||||
<div class="widget weather-widget" id="weather-widget">
|
||||
<button class="weather-widget__refresh" id="weather-refresh-btn" aria-label="Wetter aktualisieren" title="Aktualisieren">
|
||||
<i data-lucide="refresh-cw" style="width:14px;height:14px;" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div class="weather-widget__inner">
|
||||
<div class="weather-widget__main">
|
||||
<div class="weather-widget__left">
|
||||
@@ -462,4 +465,50 @@ export async function render(container, { user }) {
|
||||
wireLinks(container);
|
||||
initFab(container, _fabController.signal);
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
|
||||
// Wetter-Refresh: Button + 30-Minuten-Interval
|
||||
const refreshBtn = container.querySelector('#weather-refresh-btn');
|
||||
if (refreshBtn) {
|
||||
const doWeatherRefresh = async () => {
|
||||
refreshBtn.disabled = true;
|
||||
refreshBtn.classList.add('weather-widget__refresh--spinning');
|
||||
try {
|
||||
const res = await api.get('/weather').catch(() => ({ data: null }));
|
||||
const wWidget = container.querySelector('#weather-widget');
|
||||
if (wWidget) {
|
||||
const fresh = renderWeatherWidget(res.data ?? null);
|
||||
wWidget.outerHTML = fresh;
|
||||
const newWidget = container.querySelector('#weather-widget');
|
||||
if (newWidget && window.lucide) window.lucide.createIcons({ el: newWidget });
|
||||
wireWeatherRefresh(container);
|
||||
}
|
||||
} catch { /* silently ignore */ }
|
||||
};
|
||||
|
||||
refreshBtn.addEventListener('click', doWeatherRefresh, { signal: _fabController.signal });
|
||||
|
||||
// 30-Minuten Auto-Refresh — abortiert wenn Seite verlassen wird
|
||||
const timerId = setInterval(doWeatherRefresh, 30 * 60 * 1000);
|
||||
_fabController.signal.addEventListener('abort', () => clearInterval(timerId));
|
||||
}
|
||||
}
|
||||
|
||||
function wireWeatherRefresh(container) {
|
||||
const refreshBtn = container.querySelector('#weather-refresh-btn');
|
||||
if (!refreshBtn) return;
|
||||
const doWeatherRefresh = async () => {
|
||||
refreshBtn.disabled = true;
|
||||
refreshBtn.classList.add('weather-widget__refresh--spinning');
|
||||
try {
|
||||
const res = await api.get('/weather').catch(() => ({ data: null }));
|
||||
const wWidget = container.querySelector('#weather-widget');
|
||||
if (wWidget) {
|
||||
wWidget.outerHTML = renderWeatherWidget(res.data ?? null);
|
||||
const newWidget = container.querySelector('#weather-widget');
|
||||
if (newWidget && window.lucide) window.lucide.createIcons({ el: newWidget });
|
||||
wireWeatherRefresh(container);
|
||||
}
|
||||
} catch { /* silently ignore */ }
|
||||
};
|
||||
refreshBtn.addEventListener('click', doWeatherRefresh, { signal: _fabController.signal });
|
||||
}
|
||||
|
||||
+25
-5
@@ -21,7 +21,7 @@ const NOTE_COLORS = [
|
||||
// State
|
||||
// --------------------------------------------------------
|
||||
|
||||
let state = { notes: [], user: null };
|
||||
let state = { notes: [], user: null, filterQuery: '' };
|
||||
let _container = null;
|
||||
|
||||
// --------------------------------------------------------
|
||||
@@ -49,6 +49,12 @@ export async function render(container, { user }) {
|
||||
<div class="notes-page">
|
||||
<div class="notes-toolbar">
|
||||
<h1 class="notes-toolbar__title">Pinnwand</h1>
|
||||
<div class="notes-toolbar__search">
|
||||
<i data-lucide="search" class="notes-toolbar__search-icon" aria-hidden="true"></i>
|
||||
<input type="search" id="notes-search" class="notes-toolbar__search-input"
|
||||
placeholder="Notizen durchsuchen…" autocomplete="off"
|
||||
value="${escHtml(state.filterQuery)}">
|
||||
</div>
|
||||
<button class="btn btn--primary" id="notes-add-btn">
|
||||
<i data-lucide="plus" style="width:16px;height:16px;margin-right:4px;" aria-hidden="true"></i>
|
||||
Neue Notiz
|
||||
@@ -91,6 +97,11 @@ export async function render(container, { user }) {
|
||||
const addHandler = () => openNoteModal({ mode: 'create' });
|
||||
_container.querySelector('#notes-add-btn').addEventListener('click', addHandler);
|
||||
_container.querySelector('#fab-new-note').addEventListener('click', addHandler);
|
||||
|
||||
_container.querySelector('#notes-search').addEventListener('input', (e) => {
|
||||
state.filterQuery = e.target.value;
|
||||
renderGrid();
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
@@ -101,7 +112,16 @@ function renderGrid() {
|
||||
const grid = _container.querySelector('#notes-grid');
|
||||
if (!grid) return;
|
||||
|
||||
if (!state.notes.length) {
|
||||
const q = state.filterQuery.trim().toLowerCase();
|
||||
const visible = q
|
||||
? state.notes.filter((n) =>
|
||||
(n.title || '').toLowerCase().includes(q) ||
|
||||
(n.content || '').toLowerCase().includes(q)
|
||||
)
|
||||
: state.notes;
|
||||
|
||||
if (!visible.length) {
|
||||
const isFiltered = q.length > 0;
|
||||
grid.innerHTML = `
|
||||
<div class="empty-state" style="column-span:all;">
|
||||
<svg class="empty-state__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
|
||||
@@ -111,15 +131,15 @@ function renderGrid() {
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
<polyline points="10 9 9 9 8 9"/>
|
||||
</svg>
|
||||
<div class="empty-state__title">Noch keine Notizen</div>
|
||||
<div class="empty-state__description">Neue Notiz über den + Button erstellen.</div>
|
||||
<div class="empty-state__title">${isFiltered ? 'Keine Treffer' : 'Noch keine Notizen'}</div>
|
||||
<div class="empty-state__description">${isFiltered ? `Keine Notiz enthält „${escHtml(state.filterQuery)}".` : 'Neue Notiz über den + Button erstellen.'}</div>
|
||||
</div>
|
||||
`;
|
||||
if (window.lucide) lucide.createIcons();
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = state.notes.map((n) => renderNoteCard(n)).join('');
|
||||
grid.innerHTML = visible.map((n) => renderNoteCard(n)).join('');
|
||||
if (window.lucide) lucide.createIcons();
|
||||
stagger(grid.querySelectorAll('.note-card'));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user