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:
@@ -95,24 +95,32 @@ Das Budget-Formular hat eine „Wiederkehrend"-Checkbox und speichert `is_recurr
|
|||||||
|
|
||||||
### BL-07 — Notizen: Volltextsuche / Filter
|
### BL-07 — Notizen: Volltextsuche / Filter
|
||||||
|
|
||||||
|
**Status:** Erledigt (v0.4.0)
|
||||||
|
|
||||||
Derzeit keine Suchfunktion in der Pinnwand. Die Notizen liegen im State, eine Client-seitige Filterleiste wäre ohne API-Änderung machbar.
|
Derzeit keine Suchfunktion in der Pinnwand. Die Notizen liegen im State, eine Client-seitige Filterleiste wäre ohne API-Änderung machbar.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### BL-08 — Dashboard: Wetter-Widget Refresh
|
### BL-08 — Dashboard: Wetter-Widget Refresh
|
||||||
|
|
||||||
|
**Status:** Erledigt (v0.4.0)
|
||||||
|
|
||||||
Wetter-Widget lädt beim Seitenaufruf und hat keinen manuellen Refresh-Button. Bei langem Tab-Offenbleiben können die Daten veralten. Ein 30-Minuten-Interval oder ein Refresh-Icon wäre sinnvoll (SPEC erwähnt „Refresh 30min" implizit).
|
Wetter-Widget lädt beim Seitenaufruf und hat keinen manuellen Refresh-Button. Bei langem Tab-Offenbleiben können die Daten veralten. Ein 30-Minuten-Interval oder ein Refresh-Icon wäre sinnvoll (SPEC erwähnt „Refresh 30min" implizit).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### BL-09 — Kontakte: vCard-Import / -Export
|
### BL-09 — Kontakte: vCard-Import / -Export
|
||||||
|
|
||||||
|
**Status:** Erledigt (v0.4.0)
|
||||||
|
|
||||||
Nicht im SPEC, aber naheliegend: `.vcf`-Export eines Kontakts, Import aus vCard für Erstbefüllung.
|
Nicht im SPEC, aber naheliegend: `.vcf`-Export eines Kontakts, Import aus vCard für Erstbefüllung.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### BL-10 — PWA: Offline-Fallback für kritische Seiten
|
### BL-10 — PWA: Offline-Fallback für kritische Seiten
|
||||||
|
|
||||||
|
**Status:** Erledigt (v0.4.0)
|
||||||
|
|
||||||
Der Service Worker cached aktuell den App-Shell. Bei Offline-Nutzung fehlt eine sinnvolle Fallback-Seite mit dem Hinweis auf fehlende Verbindung und einem „Wiederholen"-Button.
|
Der Service Worker cached aktuell den App-Shell. Bei Offline-Nutzung fehlt eine sinnvolle Fallback-Seite mit dem Hinweis auf fehlende Verbindung und einem „Wiederholen"-Button.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Notes: client-side full-text search bar in toolbar — filters by title and content instantly; shows "Keine Treffer" empty state when no match
|
||||||
|
- Dashboard: weather widget refresh button (top-right corner) + automatic 30-minute refresh interval; interval is cleared when navigating away
|
||||||
|
- Contacts: vCard export button per contact (downloads .vcf file); vCard import via file input in toolbar (parses FN, TEL, EMAIL, ADR, NOTE, CATEGORIES fields)
|
||||||
|
- PWA: offline fallback page (`/offline.html`) served by service worker when network is unavailable and index.html is not cached; page includes a reload button
|
||||||
|
|
||||||
## [0.3.0] - 2026-03-31
|
## [0.3.0] - 2026-03-31
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Keine Verbindung – Oikos</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--color-bg: #1a1a2e;
|
||||||
|
--color-surface: #16213e;
|
||||||
|
--color-accent: #4A90D9;
|
||||||
|
--color-text: #e8e8f0;
|
||||||
|
--color-text-secondary: #8888a4;
|
||||||
|
--color-border: rgba(255,255,255,0.08);
|
||||||
|
--radius: 12px;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
--color-bg: #f5f5f7;
|
||||||
|
--color-surface: #ffffff;
|
||||||
|
--color-text: #1c1c1e;
|
||||||
|
--color-text-secondary: #6e6e80;
|
||||||
|
--color-border: rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 40px 32px;
|
||||||
|
max-width: 360px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
margin: 0 auto 24px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 24px;
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
button:hover { opacity: 0.85; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
|
||||||
|
<line x1="1" y1="1" x2="23" y2="23"/>
|
||||||
|
<path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55"/>
|
||||||
|
<path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39"/>
|
||||||
|
<path d="M10.71 5.05A16 16 0 0 1 22.56 9"/>
|
||||||
|
<path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88"/>
|
||||||
|
<path d="M8.53 16.11a6 6 0 0 1 6.95 0"/>
|
||||||
|
<line x1="12" y1="20" x2="12.01" y2="20"/>
|
||||||
|
</svg>
|
||||||
|
<h1>Keine Verbindung</h1>
|
||||||
|
<p>Oikos ist gerade nicht erreichbar. Bitte prüfe deine Internetverbindung und versuche es erneut.</p>
|
||||||
|
<button onclick="location.reload()">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||||
|
<polyline points="23 4 23 10 17 10"/>
|
||||||
|
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||||
|
</svg>
|
||||||
|
Erneut versuchen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -52,6 +52,11 @@ export async function render(container, { user }) {
|
|||||||
id="contacts-search" placeholder="Name, Telefon oder E-Mail suchen…"
|
id="contacts-search" placeholder="Name, Telefon oder E-Mail suchen…"
|
||||||
autocomplete="off">
|
autocomplete="off">
|
||||||
</div>
|
</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">
|
<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>
|
<i data-lucide="plus" style="width:16px;height:16px;margin-right:4px;" aria-hidden="true"></i>
|
||||||
Neu
|
Neu
|
||||||
@@ -101,6 +106,24 @@ export async function render(container, { user }) {
|
|||||||
const addHandler = () => openContactModal({ mode: 'create' });
|
const addHandler = () => openContactModal({ mode: 'create' });
|
||||||
_container.querySelector('#contacts-add-btn').addEventListener('click', addHandler);
|
_container.querySelector('#contacts-add-btn').addEventListener('click', addHandler);
|
||||||
_container.querySelector('#fab-new-contact').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>
|
||||||
<div class="contact-item__actions">
|
<div class="contact-item__actions">
|
||||||
${phone}${email}${maps}
|
${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">
|
<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>
|
<i data-lucide="trash-2" style="width:16px;height:16px;" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -327,3 +354,39 @@ function escHtml(str) {
|
|||||||
.replace(/&/g, '&').replace(/</g, '<')
|
.replace(/&/g, '&').replace(/</g, '<')
|
||||||
.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('');
|
}).join('');
|
||||||
|
|
||||||
return `
|
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__inner">
|
||||||
<div class="weather-widget__main">
|
<div class="weather-widget__main">
|
||||||
<div class="weather-widget__left">
|
<div class="weather-widget__left">
|
||||||
@@ -462,4 +465,50 @@ export async function render(container, { user }) {
|
|||||||
wireLinks(container);
|
wireLinks(container);
|
||||||
initFab(container, _fabController.signal);
|
initFab(container, _fabController.signal);
|
||||||
if (window.lucide) window.lucide.createIcons();
|
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
|
// State
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|
||||||
let state = { notes: [], user: null };
|
let state = { notes: [], user: null, filterQuery: '' };
|
||||||
let _container = null;
|
let _container = null;
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
@@ -49,6 +49,12 @@ export async function render(container, { user }) {
|
|||||||
<div class="notes-page">
|
<div class="notes-page">
|
||||||
<div class="notes-toolbar">
|
<div class="notes-toolbar">
|
||||||
<h1 class="notes-toolbar__title">Pinnwand</h1>
|
<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">
|
<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>
|
<i data-lucide="plus" style="width:16px;height:16px;margin-right:4px;" aria-hidden="true"></i>
|
||||||
Neue Notiz
|
Neue Notiz
|
||||||
@@ -91,6 +97,11 @@ export async function render(container, { user }) {
|
|||||||
const addHandler = () => openNoteModal({ mode: 'create' });
|
const addHandler = () => openNoteModal({ mode: 'create' });
|
||||||
_container.querySelector('#notes-add-btn').addEventListener('click', addHandler);
|
_container.querySelector('#notes-add-btn').addEventListener('click', addHandler);
|
||||||
_container.querySelector('#fab-new-note').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');
|
const grid = _container.querySelector('#notes-grid');
|
||||||
if (!grid) return;
|
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 = `
|
grid.innerHTML = `
|
||||||
<div class="empty-state" style="column-span:all;">
|
<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">
|
<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"/>
|
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||||
<polyline points="10 9 9 9 8 9"/>
|
<polyline points="10 9 9 9 8 9"/>
|
||||||
</svg>
|
</svg>
|
||||||
<div class="empty-state__title">Noch keine Notizen</div>
|
<div class="empty-state__title">${isFiltered ? 'Keine Treffer' : 'Noch keine Notizen'}</div>
|
||||||
<div class="empty-state__description">Neue Notiz über den + Button erstellen.</div>
|
<div class="empty-state__description">${isFiltered ? `Keine Notiz enthält „${escHtml(state.filterQuery)}".` : 'Neue Notiz über den + Button erstellen.'}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
if (window.lucide) lucide.createIcons();
|
if (window.lucide) lucide.createIcons();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
grid.innerHTML = state.notes.map((n) => renderNoteCard(n)).join('');
|
grid.innerHTML = visible.map((n) => renderNoteCard(n)).join('');
|
||||||
if (window.lucide) lucide.createIcons();
|
if (window.lucide) lucide.createIcons();
|
||||||
stagger(grid.querySelectorAll('.note-card'));
|
stagger(grid.querySelectorAll('.note-card'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -542,10 +542,46 @@
|
|||||||
* Layout mit bis zu 5-Tage-Vorhersage.
|
* Layout mit bis zu 5-Tage-Vorhersage.
|
||||||
* -------------------------------------------------------- */
|
* -------------------------------------------------------- */
|
||||||
.weather-widget {
|
.weather-widget {
|
||||||
|
position: relative;
|
||||||
background: linear-gradient(135deg, var(--color-accent) 0%, #1E5CB3 100%);
|
background: linear-gradient(135deg, var(--color-accent) 0%, #1E5CB3 100%);
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.weather-widget__refresh {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--space-2);
|
||||||
|
right: var(--space-2);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: none;
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 1;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-widget__refresh:hover {
|
||||||
|
background: rgba(255,255,255,0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-widget__refresh:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-widget__refresh--spinning i {
|
||||||
|
animation: spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
.weather-widget__main {
|
.weather-widget__main {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -43,6 +43,39 @@
|
|||||||
font-weight: var(--font-weight-bold);
|
font-weight: var(--font-weight-bold);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notes-toolbar__search {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-toolbar__search-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: var(--space-2);
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-toolbar__search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-2) var(--space-2) var(--space-2) calc(var(--space-2) * 2 + 16px);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-toolbar__search-input:focus {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------
|
/* --------------------------------------------------------
|
||||||
* Masonry-Grid
|
* Masonry-Grid
|
||||||
* -------------------------------------------------------- */
|
* -------------------------------------------------------- */
|
||||||
|
|||||||
+11
-4
@@ -12,9 +12,9 @@
|
|||||||
* API: Immer Netzwerk (kein Caching von Nutzerdaten)
|
* API: Immer Netzwerk (kein Caching von Nutzerdaten)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const SHELL_CACHE = 'oikos-shell-v19';
|
const SHELL_CACHE = 'oikos-shell-v20';
|
||||||
const PAGES_CACHE = 'oikos-pages-v19';
|
const PAGES_CACHE = 'oikos-pages-v20';
|
||||||
const ASSETS_CACHE = 'oikos-assets-v19';
|
const ASSETS_CACHE = 'oikos-assets-v20';
|
||||||
const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, ASSETS_CACHE];
|
const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, ASSETS_CACHE];
|
||||||
|
|
||||||
// App-Shell: sofort benötigt für ersten Render
|
// App-Shell: sofort benötigt für ersten Render
|
||||||
@@ -41,6 +41,7 @@ const APP_SHELL = [
|
|||||||
'/styles/budget.css',
|
'/styles/budget.css',
|
||||||
'/styles/settings.css',
|
'/styles/settings.css',
|
||||||
'/components/oikos-install-prompt.js',
|
'/components/oikos-install-prompt.js',
|
||||||
|
'/offline.html',
|
||||||
'/manifest.json',
|
'/manifest.json',
|
||||||
'/favicon.ico',
|
'/favicon.ico',
|
||||||
'/icons/favicon-32.png',
|
'/icons/favicon-32.png',
|
||||||
@@ -157,6 +158,10 @@ async function networkFirst(request, cacheName) {
|
|||||||
const shell = await cache.match('/index.html');
|
const shell = await cache.match('/index.html');
|
||||||
if (shell) return shell;
|
if (shell) return shell;
|
||||||
|
|
||||||
|
// Letzter Ausweg: Offline-Seite
|
||||||
|
const offline = await caches.match('/offline.html');
|
||||||
|
if (offline) return offline;
|
||||||
|
|
||||||
return new Response('Keine Verbindung', {
|
return new Response('Keine Verbindung', {
|
||||||
status: 503,
|
status: 503,
|
||||||
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||||
@@ -192,10 +197,12 @@ async function staleWhileRevalidate(request, cacheName) {
|
|||||||
const networkResponse = await networkPromise;
|
const networkResponse = await networkPromise;
|
||||||
if (networkResponse) return networkResponse;
|
if (networkResponse) return networkResponse;
|
||||||
|
|
||||||
// Offline-Fallback: SPA-Shell für Navigation
|
// Offline-Fallback für Navigation
|
||||||
if (request.mode === 'navigate') {
|
if (request.mode === 'navigate') {
|
||||||
const shell = await caches.match('/index.html');
|
const shell = await caches.match('/index.html');
|
||||||
if (shell) return shell;
|
if (shell) return shell;
|
||||||
|
const offline = await caches.match('/offline.html');
|
||||||
|
if (offline) return offline;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Letzter Ausweg: leere 503-Antwort statt Promise-Rejection
|
// Letzter Ausweg: leere 503-Antwort statt Promise-Rejection
|
||||||
|
|||||||
@@ -160,4 +160,42 @@ router.get('/meta', (_req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/contacts/:id/vcard
|
||||||
|
* Kontakt als vCard 3.0 (.vcf) exportieren.
|
||||||
|
* Response: text/vcard Dateidownload
|
||||||
|
*/
|
||||||
|
router.get('/:id/vcard', (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
const contact = db.get().prepare('SELECT * FROM contacts WHERE id = ?').get(id);
|
||||||
|
if (!contact) return res.status(404).json({ error: 'Kontakt nicht gefunden', code: 404 });
|
||||||
|
|
||||||
|
const esc = (v) => String(v || '').replace(/\n/g, '\\n').replace(/,/g, '\\,').replace(/;/g, '\\;');
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
'BEGIN:VCARD',
|
||||||
|
'VERSION:3.0',
|
||||||
|
`FN:${esc(contact.name)}`,
|
||||||
|
`N:${esc(contact.name)};;;;`,
|
||||||
|
];
|
||||||
|
if (contact.phone) lines.push(`TEL;TYPE=VOICE:${esc(contact.phone)}`);
|
||||||
|
if (contact.email) lines.push(`EMAIL:${esc(contact.email)}`);
|
||||||
|
if (contact.address) lines.push(`ADR;TYPE=HOME:;;${esc(contact.address)};;;;`);
|
||||||
|
if (contact.notes) lines.push(`NOTE:${esc(contact.notes)}`);
|
||||||
|
if (contact.category) lines.push(`CATEGORIES:${esc(contact.category)}`);
|
||||||
|
lines.push('END:VCARD');
|
||||||
|
|
||||||
|
const vcf = lines.join('\r\n');
|
||||||
|
const filename = encodeURIComponent(contact.name.replace(/[^a-zA-Z0-9-_ ]/g, '_')) + '.vcf';
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/vcard; charset=utf-8');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
|
res.send(vcf);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[contacts/GET /:id/vcard]', err);
|
||||||
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
Reference in New Issue
Block a user