Sprint 12+13: Tagebuch Day-One-Redesign, Notiz-Feature, Icon-Fixes, SW by-v405

Tagebuch:
- Day-One-Listenansicht: Wochentag + Tageszahl + Meta-Zeile (Zeit/Ort/Wetter)
- 4 Ansichten: Liste, Medien-Mosaik, Kalender (mit Sprungbuttons), Karte (GPS-Marker)
- Detail-Ansicht inline im Content-Bereich (kein Fullscreen-Overlay mehr)
- Hero-Bild vollständig sichtbar (object-fit:contain), Lightbox mit Safe-Area
- 2-Spalten-Layout Desktop: Text + Leaflet-Karte + POI-Liste
- EXIF-GPS-Extraktion bei Foto-Upload, historisches Wetter via Archive-API
- NoteStation-Import: Fotos in diary_media (80 Einträge migriert, 94 Medien)
- Stats-Endpoints: /diary/stats, /diary/calendar, /diary/locations

Notiz-Feature:
- Generische notes-Tabelle (parent_type + parent_id + meta_json)
- 📝-Button in 8 Bereichen, Notizblock-Seite mit KI-Analyse
- KI-Toggle in Einstellungen, notes_ki_enabled in User-Profil

Icons & Design:
- fill:currentColor Fix für welcome/onboarding/friends.js
- --c-icon Variable, --c-text-muted Dark Mode aufgehellt
- 15+ neue Phosphor-Icons aus lokaler Kopie
- CSS Network-First im SW, Cache-Control-Middleware

Infrastruktur:
- Wiki-Anreicherungs-Scheduler-Jobs entfernt (abgeschlossen)
- auth.py: notes_ki_enabled + is_social_media im User-Response
This commit is contained in:
rene 2026-04-25 20:44:46 +02:00
parent 95f91fdc00
commit 553e9e7854
35 changed files with 4558 additions and 370 deletions

View file

@ -118,6 +118,7 @@ const API = (() => {
const q = new URLSearchParams(params).toString();
return get(`/dogs/${dogId}/diary${q ? '?' + q : ''}`);
},
stats(dogId) { return get(`/dogs/${dogId}/diary/stats`); },
get(dogId, entryId) { return get(`/dogs/${dogId}/diary/${entryId}`); },
create(dogId, data) { return post(`/dogs/${dogId}/diary`, data); },
update(dogId, id, data){ return patch(`/dogs/${dogId}/diary/${id}`, data); },
@ -137,6 +138,8 @@ const API = (() => {
nearby(dogId, lat, lon) {
return get(`/dogs/${dogId}/diary/nearby?lat=${lat}&lon=${lon}`);
},
locations(dogId) { return get(`/dogs/${dogId}/diary/locations`); },
calendar(dogId) { return get(`/dogs/${dogId}/diary/calendar`); },
};
// ----------------------------------------------------------
@ -559,6 +562,30 @@ const API = (() => {
},
};
// ----------------------------------------------------------
// NOTIZEN
// ----------------------------------------------------------
const notes = {
get(parentType, parentId) {
return get(`/notes/${parentType}/${parentId}`);
},
getAll(params) {
return get('/notes?' + new URLSearchParams(params || {}).toString());
},
analyse() {
return post('/notes/ki-analyse', {});
},
create(parentType, parentId, data) {
return post(`/notes/${parentType}/${parentId}`, data);
},
update(id, data) {
return patch(`/notes/${id}`, data);
},
delete(id) {
return del(`/notes/${id}`);
},
};
// ----------------------------------------------------------
// ERROR-KLASSE
// ----------------------------------------------------------
@ -576,7 +603,7 @@ const API = (() => {
get, post, put, patch, del, upload,
auth, dogs, diary, health, tieraerzte, poison,
places, routes, walks, events, sitting, forum, lost, knigge, weather, push,
friends, chat, webcal, importData, sharing, widget, notifications, services, ratings, sittingAccess, training,
friends, chat, webcal, importData, sharing, widget, notifications, services, ratings, sittingAccess, training, notes,
subscribeToPush, getLocation,
APIError,
};

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '351'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '385'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const App = (() => {

File diff suppressed because it is too large Load diff

View file

@ -28,6 +28,10 @@ window.Page_dog_profile = (() => {
if (e.target.closest('#profile-goto-login')) {
App.navigate('settings');
}
if (e.target.closest('[data-action="goto-weight"]')) {
App.navigate('health', true, { tab: 'gewicht', openForm: true });
return;
}
});
await _render();
@ -119,7 +123,7 @@ window.Page_dog_profile = (() => {
</div>
` : ''}
${dog.gewicht_kg ? `
<div class="card" style="padding:var(--space-3)">
<div class="card" style="padding:var(--space-3);cursor:pointer" data-action="goto-weight">
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
margin-bottom:2px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#scales"></use></svg> Gewicht</div>
<div style="font-weight:500;font-size:var(--text-sm)">${dog.gewicht_kg} kg</div>

View file

@ -186,7 +186,7 @@ window.Page_erste_hilfe = (() => {
<th style="padding:var(--space-2) var(--space-3);text-align:left;font-weight:var(--weight-semibold)">Bedeutung</th>
</tr></thead>
<tbody>
<tr><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border)">Rosa, feucht</td><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border);color:#22c55e;font-weight:var(--weight-semibold)">Normal</td></tr>
<tr><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border)">Rosa, feucht</td><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border);color:var(--c-success);font-weight:var(--weight-semibold)">Normal</td></tr>
<tr style="background:var(--c-surface-2)"><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border)">Blass / weiß</td><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border);color:var(--c-danger)">Schock, Blutverlust, Vergiftung</td></tr>
<tr><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border)">Blau / grau</td><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border);color:var(--c-danger);font-weight:var(--weight-semibold)">Sauerstoffmangel NOTFALL</td></tr>
<tr style="background:var(--c-surface-2)"><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border)">Gelb</td><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border);color:var(--c-warning)">Leberprobleme</td></tr>
@ -239,6 +239,7 @@ window.Page_erste_hilfe = (() => {
`;
_bindTabs();
_bindAccordions();
_bindNoteButtons();
_activateTab('lebensgefahr');
}
@ -340,6 +341,10 @@ window.Page_erste_hilfe = (() => {
${massnahmenHtml}
${warnHtml}
${e.extra || ''}
<div style="margin-top:var(--space-3);text-align:right">
<button class="btn btn-ghost btn-xs eh-note-btn" style="font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 8px"
data-kat-id="${katId}" data-titel="${e.titel.replace(/"/g,'&quot;')}"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
</div>
</div>
</div>
`;
@ -382,6 +387,102 @@ window.Page_erste_hilfe = (() => {
});
}
function _bindNoteButtons() {
_container.querySelectorAll('.eh-note-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const katId = btn.dataset.katId;
const titel = btn.dataset.titel;
const kat = KATEGORIEN.find(k => k.id === katId);
const label = kat ? `${kat.label}${titel}` : titel;
_openNoteModal('erste_hilfe', katId, label, null);
});
});
}
// ----------------------------------------------------------------
// NOTIZ-MODAL (custom DOM, kein UI.modal um Konflikte zu vermeiden)
// ----------------------------------------------------------------
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';
const _esc = s => s ? String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;') : '';
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, 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, parentId, payload);
}
UI.toast.success('Notiz gespeichert.');
_close();
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Speichern.');
UI.setLoading(saveBtn, false);
}
});
}
// ----------------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------------

View file

@ -226,7 +226,14 @@ window.Page_events = (() => {
</a>
</div>` : ''}
</div>
${isOwn ? `<button class="btn-icon" data-ev-edit="${ev.id}" title="Bearbeiten">${_icon('pencil-simple')}</button>` : ''}
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:var(--space-1)">
${isOwn ? `<button class="btn-icon" data-ev-edit="${ev.id}" title="Bearbeiten" onclick="event.stopPropagation()">${_icon('pencil-simple')}</button>` : ''}
${_state.user ? `<button class="btn-icon ev-note-btn" data-ev-note-id="${ev.id}"
data-ev-note-label="${UI.escape(ev.titel + ' ' + ev.datum)}"
data-ev-note-ort="${UI.escape(ev.ort_name || '')}"
title="Notiz" style="color:var(--c-text-muted)" onclick="event.stopPropagation()">
${_icon('note-pencil')}</button>` : ''}
</div>
</div>
`;
}
@ -268,7 +275,7 @@ window.Page_events = (() => {
const popup = `
<div style="min-width:180px">
<strong>${UI.escape(ev.titel)}</strong><br>
<span style="color:#666;font-size:12px">${datum}</span><br>
<span style="color:var(--c-text-muted);font-size:12px">${datum}</span><br>
${ev.ort_name ? `<span style="font-size:12px">📍 ${UI.escape(ev.ort_name)}</span><br>` : ''}
${ev.beschreibung ? `<span style="font-size:12px">${UI.escape(ev.beschreibung.slice(0, 80))}${ev.beschreibung.length > 80 ? '…' : ''}</span><br>` : ''}
<a href="#" onclick="event.preventDefault();Page_events._openDetail(${ev.id})"
@ -634,11 +641,77 @@ window.Page_events = (() => {
return;
}
// Notiz-Button
const noteBtn = e.target.closest('.ev-note-btn');
if (noteBtn) {
e.stopPropagation();
_openNoteModal(
'event',
parseInt(noteBtn.dataset.evNoteId),
noteBtn.dataset.evNoteLabel,
noteBtn.dataset.evNoteOrt || null
);
return;
}
// Karten-Klick → Detail
const card = e.target.closest('[data-ev-id]');
if (card) { _showDetail(parseInt(card.dataset.evId)); }
}
// ----------------------------------------------------------
// Notiz-Modal (Custom DOM — kein UI.modal um Konflikte zu vermeiden)
// ----------------------------------------------------------
async function _openNoteModal(parentType, parentId, parentLabel, locationName) {
let existingNote = null;
try { existingNote = await API.notes.get(parentType, String(parentId)); } catch {}
const ovl = document.createElement('div');
ovl.style.cssText = 'position:fixed;inset:0;z-index:1200;background:rgba(0,0,0,0.55);display:flex;align-items:flex-end;justify-content:center';
ovl.innerHTML = `
<div style="width:100%;max-width:600px;background:var(--c-surface);border-radius:var(--radius-lg) var(--radius-lg) 0 0;
padding:var(--space-4);box-sizing:border-box;max-height:80vh;display:flex;flex-direction:column">
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)">
<svg class="ph-icon" aria-hidden="true" style="color:var(--c-primary)"><use href="/icons/phosphor.svg#note-pencil"></use></svg>
<span style="font-weight:600;flex:1"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz ${UI.escape(parentLabel)}</span>
<button id="ev-note-close" style="background:none;border:none;cursor:pointer;color:var(--c-text-muted);padding:4px">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
</div>
<textarea id="ev-note-text" rows="5"
style="width:100%;box-sizing:border-box;padding:var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
font-size:var(--text-sm);font-family:inherit;
background:var(--c-bg);color:var(--c-text);resize:vertical;flex:1"
placeholder="Deine Notiz zu diesem Event…">${UI.escape(existingNote?.text || '')}</textarea>
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-3)">
<button id="ev-note-cancel" class="btn btn-secondary flex-1">Abbrechen</button>
<button id="ev-note-save" class="btn btn-primary flex-1">Speichern</button>
</div>
</div>
`;
document.body.appendChild(ovl);
const close = () => ovl.remove();
ovl.querySelector('#ev-note-close')?.addEventListener('click', close);
ovl.querySelector('#ev-note-cancel')?.addEventListener('click', close);
ovl.addEventListener('click', e => { if (e.target === ovl) close(); });
ovl.querySelector('#ev-note-save')?.addEventListener('click', async () => {
const text = ovl.querySelector('#ev-note-text')?.value?.trim() || '';
const payload = { text, parent_label: parentLabel, location_name: locationName || null };
try {
if (existingNote?.id) {
await API.notes.update(existingNote.id, 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.'); }
});
}
return { init, refresh, openNew, _openDetail: _showDetail };
})();

View file

@ -44,7 +44,7 @@ window.Page_friends = (() => {
<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="width:18px;height:18px;color:var(--c-primary)" aria-hidden="true">
<svg style="fill:currentColor;width:18px;height:18px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#link"></use>
</svg>
</div>

View file

@ -34,10 +34,17 @@ window.Page_health = (() => {
// ----------------------------------------------------------
// LIFECYCLE
// ----------------------------------------------------------
async function init(container, appState) {
async function init(container, appState, params) {
_container = container;
_appState = appState;
if (params?.tab) {
const valid = _getTabs().some(t => t.key === params.tab);
if (valid) _activeTab = params.tab;
}
await _render();
if (params?.openForm) {
setTimeout(() => _showForm(null, _activeTab), 200);
}
}
async function refresh() {
@ -400,6 +407,10 @@ window.Page_health = (() => {
Nächste Impfung: ${UI.time.format(e.naechstes + 'T00:00:00')} ${ampel.icon}
</div>` : ''}
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
<button class="btn btn-ghost btn-xs" style="margin-top:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 6px"
data-action="open-note" data-entry-id="${e.id}"
data-label="${_esc(e.bezeichnung)}"
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
</div>
</div>
`;
@ -445,6 +456,10 @@ window.Page_health = (() => {
</div>` : ''}
${e.diagnose ? `<div class="health-card-note"><b>Diagnose:</b> ${_esc(e.diagnose)}</div>` : ''}
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
<button class="btn btn-ghost btn-xs" style="margin-top:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 6px"
data-action="open-note" data-entry-id="${e.id}"
data-label="${_esc(e.bezeichnung)}"
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
</div>
</div>
`;
@ -493,6 +508,10 @@ window.Page_health = (() => {
</span>
</div>
${e.notiz ? `<div class="health-card-note" style="padding-top:var(--space-1)">${_esc(e.notiz)}</div>` : ''}
<button class="btn btn-ghost btn-xs" style="margin-top:var(--space-1);font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 6px"
data-action="open-note" data-entry-id="${e.id}"
data-label="Gewicht ${_esc(e.datum)}"
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
</div>
`).join('');
@ -726,6 +745,10 @@ window.Page_health = (() => {
${interval ? ` · Abstand zur Vorherigen: ${interval} Tage` : ''}
</div>
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
<button class="btn btn-ghost btn-xs" style="margin-top:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 6px"
data-action="open-note" data-entry-id="${e.id}"
data-label="Läufigkeit ${_esc(e.datum)}"
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
</div>
</div>`;
}).join('');
@ -760,6 +783,10 @@ window.Page_health = (() => {
${e.bis_datum ? ` · bis ${UI.time.format(e.bis_datum + 'T00:00:00')}` : ''}
</div>
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
<button class="btn btn-ghost btn-xs" style="margin-top:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 6px"
data-action="open-note" data-entry-id="${e.id}"
data-label="${_esc(e.bezeichnung)}"
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
</div>
</div>
`).join('')}
@ -797,6 +824,10 @@ window.Page_health = (() => {
</div>
${e.reaktion ? `<div class="health-card-note"><b>Reaktion:</b> ${_esc(e.reaktion)}</div>` : ''}
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
<button class="btn btn-ghost btn-xs" style="margin-top:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 6px"
data-action="open-note" data-entry-id="${e.id}"
data-label="${_esc(e.bezeichnung)}"
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
</div>
</div>
`).join('');
@ -837,6 +868,10 @@ window.Page_health = (() => {
${count > 1 ? ` · ${count} Dateien` : ''}
</div>
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
<button class="btn btn-ghost btn-xs" style="margin-top:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 6px"
data-action="open-note" data-entry-id="${e.id}"
data-label="${_esc(e.bezeichnung)}"
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
${count
? `<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2);align-items:center;flex-wrap:wrap">
${mediaList.slice(0, 3).map(m => m.media_type === 'pdf'
@ -874,6 +909,14 @@ window.Page_health = (() => {
const entry = (_data[_activeTab] || []).find(e => e.id === id);
if (entry) card.addEventListener('click', () => _openDetail(entry));
});
content.querySelectorAll('[data-action="open-note"]').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const id = parseInt(btn.dataset.entryId);
const label = btn.dataset.label || '';
_openNoteModal('health', id, label, null);
});
});
// Praxis öffnen
content.querySelectorAll('[data-action="open-praxis"]').forEach(el => {
el.addEventListener('click', () => {
@ -1166,6 +1209,9 @@ window.Page_health = (() => {
if (!_data[t]) _data[t] = [];
_data[t].unshift(saved);
UI.toast.success('Eintrag erstellt.');
if (t === 'gewicht' && saved.wert) {
_appState.activeDog.gewicht_kg = saved.wert;
}
}
// Multi-File-Upload
@ -1830,6 +1876,89 @@ window.Page_health = (() => {
.replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// NOTIZ-MODAL (custom DOM, kein UI.modal um Konflikte zu vermeiden)
// ----------------------------------------------------------
async function _openNoteModal(parentType, parentId, parentLabel, locationName) {
// Vorhandenes Modal entfernen falls noch offen
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;
// Vorhandene Notiz laden
try {
const existing = await API.notes.get(parentType, 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, 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, openNew, onDogChange };
})();

View file

@ -0,0 +1,693 @@
/* ============================================================
BAN YARO Notizblock
Seiten-Modul: Alle Notizen mit Filter, Suche, Sortierung und KI-Analyse.
============================================================ */
window.Page_notes = (() => {
let _container = null;
let _appState = null;
let _notes = [];
// Aktueller Filter-/Such-Zustand
let _filterType = ''; // '' = alle
let _sortMode = 'newest'; // newest | type | location
let _searchQ = '';
let _searchTimer = null;
// KI-Panel
let _kiOpen = false;
let _kiLoading = false;
let _kiSuggestions = null;
let _kiError = null;
// ----------------------------------------------------------
// Rubrik-Konfiguration
// ----------------------------------------------------------
const RUBRIKEN = [
{ type: '', label: 'Alle', color: 'var(--c-text-muted)', icon: 'note' },
{ type: 'health', label: 'Gesundheit', color: '#e74c3c', icon: 'heart' },
{ type: 'diary', label: 'Tagebuch', color: '#C4843A', icon: 'book-open' },
{ type: 'training_session', label: 'Training', color: '#27ae60', icon: 'target' },
{ type: 'route', label: 'Routen', color: '#2980b9', icon: 'path' },
{ type: 'event', label: 'Events', color: '#8e44ad', icon: 'calendar' },
{ type: 'walk', label: 'Gassi-Treffen',color: '#f39c12', icon: 'paw-print' },
{ type: 'sitting', label: 'Sitting', color: '#16a085', icon: 'house-line' },
{ type: 'erste_hilfe', label: 'Erste Hilfe', color: '#c0392b', icon: 'first-aid' },
];
function _rubrik(type) {
return RUBRIKEN.find(r => r.type === type) || { type, label: type, color: 'var(--c-text-muted)', icon: 'note' };
}
// ----------------------------------------------------------
// Hilfsfunktionen
// ----------------------------------------------------------
function _esc(s) {
if (!s) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function _formatTime(isoStr) {
if (!isoStr) return '';
try {
const d = new Date(isoStr.replace(' ', 'T') + (isoStr.includes('T') || isoStr.endsWith('Z') ? '' : 'Z'));
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
} catch (_) { return ''; }
}
function _dateGroup(isoStr) {
if (!isoStr) return 'Älteres';
try {
const d = new Date(isoStr.replace(' ', 'T') + (isoStr.includes('T') || isoStr.endsWith('Z') ? '' : 'Z'));
const now = new Date();
const diffDays = (now - d) / 86400000;
if (diffDays < 1 && d.getDate() === now.getDate()) return 'Heute';
if (diffDays < 7) return 'Diese Woche';
return 'Älteres';
} catch (_) { return 'Älteres'; }
}
function _truncate(str, max = 150) {
if (!str) return '';
return str.length > max ? str.slice(0, max) + '…' : str;
}
// ----------------------------------------------------------
// Daten laden
// ----------------------------------------------------------
async function _load() {
const params = {};
if (_filterType) params.parent_type = _filterType;
if (_sortMode !== 'newest') params.sort = _sortMode;
if (_searchQ) params.q = _searchQ;
return await API.notes.getAll(params);
}
// ----------------------------------------------------------
// Filter/Sortierung anwenden (client-seitig falls API alles zurückgibt)
// ----------------------------------------------------------
function _applySort(list) {
const copy = [...list];
if (_sortMode === 'newest') {
copy.sort((a, b) => new Date(b.updated_at || b.created_at) - new Date(a.updated_at || a.created_at));
} else if (_sortMode === 'type') {
copy.sort((a, b) => (a.parent_type || '').localeCompare(b.parent_type || '', 'de'));
} else if (_sortMode === 'location') {
copy.sort((a, b) => (a.location_name || '').localeCompare(b.location_name || '', 'de'));
}
return copy;
}
// ----------------------------------------------------------
// Rendern
// ----------------------------------------------------------
function _render() {
const kiEnabled = _appState?.user?.notes_ki_enabled !== 0;
const sorted = _applySort(_notes);
// Gruppen aufbauen
const groups = { 'Heute': [], 'Diese Woche': [], 'Älteres': [] };
sorted.forEach(n => {
const g = _dateGroup(n.updated_at || n.created_at);
groups[g].push(n);
});
const groupHtml = Object.entries(groups)
.filter(([, items]) => items.length > 0)
.map(([label, items]) => `
<div class="notes-group">
<div class="notes-group-label">${_esc(label)}</div>
${items.map(_noteCard).join('')}
</div>
`).join('');
_container.innerHTML = `
<div class="notes-page">
<!-- Header -->
<div class="notes-header">
<h2 class="notes-title">Notizblock</h2>
<span class="notes-count">${_notes.length} Notiz${_notes.length !== 1 ? 'en' : ''}</span>
</div>
<!-- KI-Panel -->
${kiEnabled ? _kiPanelHtml() : ''}
<!-- Filter-Chips -->
<div class="notes-filter-chips">
${RUBRIKEN.map(r => `
<button class="notes-chip ${_filterType === r.type ? 'notes-chip--active' : ''}"
data-type="${_esc(r.type)}"
style="${_filterType === r.type ? `--chip-color:${r.color}` : ''}">
${_esc(r.label)}
</button>
`).join('')}
</div>
<!-- Suche + Sortierung -->
<div class="notes-toolbar">
<div class="notes-search-wrap">
<i class="ph ph-magnifying-glass notes-search-icon"></i>
<input id="notes-search" type="search" class="notes-search-input"
placeholder="Suche…" value="${_esc(_searchQ)}">
</div>
<div class="notes-sort-btns">
<button class="notes-sort-btn ${_sortMode === 'newest' ? 'notes-sort-btn--active' : ''}"
data-sort="newest">Neueste</button>
<button class="notes-sort-btn ${_sortMode === 'type' ? 'notes-sort-btn--active' : ''}"
data-sort="type">Rubrik</button>
<button class="notes-sort-btn ${_sortMode === 'location' ? 'notes-sort-btn--active' : ''}"
data-sort="location">Ort</button>
</div>
</div>
<!-- Liste -->
<div class="notes-list">
${sorted.length === 0
? UI.emptyState({ icon: 'note', title: 'Keine Notizen', text: 'Füge Notizen zu Trainingseinheiten oder anderen Einträgen hinzu.' })
: groupHtml
}
</div>
</div>
<style>
.notes-page { padding: var(--space-4); display: flex; flex-direction: column; gap: var(--space-3); }
.notes-header { display: flex; align-items: center; justify-content: space-between; }
.notes-title { font-size: var(--text-lg); font-weight: var(--weight-bold); color: var(--c-text); margin: 0; }
.notes-count { font-size: var(--text-xs); color: var(--c-text-muted); }
/* KI-Panel */
.notes-ki-panel { background: var(--c-surface-2); border: 1.5px solid var(--c-border); border-radius: var(--radius-lg); overflow: hidden; }
.notes-ki-header { display: flex; align-items: center; justify-content: space-between; padding: var(--space-3) var(--space-4); cursor: pointer; gap: var(--space-2); }
.notes-ki-header-left { display: flex; align-items: center; gap: var(--space-2); font-size: var(--text-sm); font-weight: var(--weight-semibold); color: var(--c-text); }
.notes-ki-chevron { transition: transform .2s; color: var(--c-text-muted); }
.notes-ki-chevron--open { transform: rotate(180deg); }
.notes-ki-body { padding: var(--space-3) var(--space-4) var(--space-4); border-top: 1px solid var(--c-border); }
.notes-ki-btn { padding: var(--space-2) var(--space-4); border-radius: var(--radius-md); border: none; background: var(--c-primary); color: #fff; font-size: var(--text-sm); font-weight: var(--weight-semibold); cursor: pointer; }
.notes-ki-btn:disabled { opacity: .6; cursor: default; }
.notes-ki-suggestions { margin-top: var(--space-3); font-size: var(--text-sm); color: var(--c-text); line-height: 1.6; }
.notes-ki-suggestions ul { margin: var(--space-2) 0 0; padding-left: var(--space-4); }
.notes-ki-suggestions li { margin-bottom: var(--space-1); }
.notes-ki-error { margin-top: var(--space-2); font-size: var(--text-sm); color: var(--c-danger); }
/* Filter-Chips */
.notes-filter-chips { display: flex; gap: var(--space-2); overflow-x: auto; padding-bottom: 2px; scrollbar-width: none; }
.notes-filter-chips::-webkit-scrollbar { display: none; }
.notes-chip { flex-shrink: 0; font-size: var(--text-xs); font-weight: var(--weight-semibold); padding: 4px var(--space-3); border-radius: 999px; border: 1.5px solid var(--c-border); background: var(--c-surface-2); color: var(--c-text-secondary); cursor: pointer; white-space: nowrap; transition: background .15s, color .15s, border-color .15s; }
.notes-chip--active { background: var(--chip-color, var(--c-primary)); color: #fff; border-color: var(--chip-color, var(--c-primary)); }
/* Toolbar */
.notes-toolbar { display: flex; gap: var(--space-2); align-items: center; }
.notes-search-wrap { position: relative; flex: 1; }
.notes-search-icon { position: absolute; left: var(--space-3); top: 50%; transform: translateY(-50%); color: var(--c-text-muted); font-size: 1rem; pointer-events: none; }
.notes-search-input { width: 100%; padding: var(--space-2) var(--space-3) var(--space-2) calc(var(--space-3) + 1.3rem); border: 1.5px solid var(--c-border); border-radius: var(--radius-md); font-size: var(--text-sm); background: var(--c-surface); color: var(--c-text); outline: none; box-sizing: border-box; }
.notes-search-input:focus { border-color: var(--c-primary); }
.notes-sort-btns { display: flex; border: 1.5px solid var(--c-border); border-radius: var(--radius-md); overflow: hidden; flex-shrink: 0; }
.notes-sort-btn { padding: var(--space-2) var(--space-3); font-size: var(--text-xs); font-weight: var(--weight-semibold); border: none; background: var(--c-surface-2); color: var(--c-text-secondary); cursor: pointer; transition: background .15s, color .15s; border-right: 1px solid var(--c-border); }
.notes-sort-btn:last-child { border-right: none; }
.notes-sort-btn--active { background: var(--c-primary); color: #fff; }
/* Gruppen */
.notes-group { display: flex; flex-direction: column; gap: var(--space-2); }
.notes-group-label { font-size: var(--text-xs); font-weight: var(--weight-semibold); color: var(--c-text-muted); text-transform: uppercase; letter-spacing: .05em; padding: var(--space-1) 0; }
/* Karten */
.notes-card { background: var(--c-surface); border: 1px solid var(--c-border); border-radius: var(--radius-lg); padding: var(--space-3) var(--space-4); display: flex; flex-direction: column; gap: var(--space-2); }
.notes-card-top { display: flex; align-items: flex-start; gap: var(--space-2); }
.notes-rubrik-chip { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-xs); font-weight: var(--weight-semibold); padding: 2px var(--space-2); border-radius: 999px; flex-shrink: 0; }
.notes-parent-label { font-size: var(--text-xs); color: var(--c-text-secondary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; align-self: center; }
.notes-card-meta { display: flex; align-items: center; gap: var(--space-2); font-size: var(--text-xs); color: var(--c-text-muted); }
.notes-card-actions { display: flex; gap: var(--space-2); margin-left: auto; flex-shrink: 0; }
.notes-card-text { font-size: var(--text-sm); color: var(--c-text); line-height: 1.55; white-space: pre-wrap; margin: 0; }
.notes-micro-badges { display: flex; flex-wrap: wrap; gap: var(--space-1); }
.notes-micro-badge { font-size: var(--text-xs); padding: 2px 6px; border-radius: var(--radius-sm); background: var(--c-surface-2); color: var(--c-text-secondary); }
.notes-action-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); border: 1px solid var(--c-border); background: var(--c-surface-2); color: var(--c-text-muted); cursor: pointer; font-size: 1rem; transition: background .15s, color .15s; }
.notes-action-btn:hover { background: var(--c-surface); color: var(--c-text); }
.notes-action-btn--danger:hover { background: #fef2f2; color: var(--c-danger); border-color: var(--c-danger); }
.notes-list { display: flex; flex-direction: column; gap: var(--space-4); }
</style>
`;
_bindEvents();
}
// ----------------------------------------------------------
// KI-Panel HTML
// ----------------------------------------------------------
function _kiPanelHtml() {
return `
<div class="notes-ki-panel" id="notes-ki-panel">
<div class="notes-ki-header" id="notes-ki-toggle">
<div class="notes-ki-header-left">
<i class="ph ph-robot"></i>
Muster-Analyse
</div>
<i class="ph ph-caret-down notes-ki-chevron ${_kiOpen ? 'notes-ki-chevron--open' : ''}" id="notes-ki-chevron"></i>
</div>
${_kiOpen ? `
<div class="notes-ki-body" id="notes-ki-body">
<button class="notes-ki-btn" id="notes-ki-analyse-btn" ${_kiLoading ? 'disabled' : ''}>
${_kiLoading ? '<i class="ph ph-spinner-gap"></i> Analysiere…' : 'Analysieren'}
</button>
${_kiError ? `<div class="notes-ki-error"><i class="ph ph-warning-circle"></i> ${_esc(_kiError)}</div>` : ''}
${_kiSuggestions ? `
<div class="notes-ki-suggestions">
<ul>
${_kiSuggestions.map(s => `<li>${_esc(s)}</li>`).join('')}
</ul>
</div>
` : ''}
</div>
` : ''}
</div>
`;
}
// ----------------------------------------------------------
// Notiz-Karte HTML
// ----------------------------------------------------------
function _noteCard(note) {
const rb = _rubrik(note.parent_type);
const meta = note.meta_json || {};
const microBadges = [];
if (meta.erfolgsquote) microBadges.push('🐾'.repeat(meta.erfolgsquote));
if (meta.umgebung) microBadges.push({ zuhause: '🏠 Zuhause', natur: '🌿 Natur', stadt: '🌆 Stadt' }[meta.umgebung] || meta.umgebung);
if (meta.hund_stimmung) microBadges.push({ super: '😊 Super', ok: '😐 Ok', mude: '😔 Müde' }[meta.hund_stimmung] || meta.hund_stimmung);
const hasLocation = !!note.location_name;
return `
<div class="notes-card" data-id="${note.id}">
<!-- Top-Zeile: Rubrik-Chip + parent_label + Zeit + Buttons -->
<div class="notes-card-top">
<span class="notes-rubrik-chip"
style="background:${rb.color}22;color:${rb.color}">
<i class="ph ph-${rb.icon}"></i>
${_esc(rb.label)}
</span>
${note.parent_label
? `<span class="notes-parent-label" title="${_esc(note.parent_label)}">${_esc(note.parent_label)}</span>`
: ''
}
<div class="notes-card-actions">
<button class="notes-action-btn notes-edit-btn" data-id="${note.id}" title="Bearbeiten">
<i class="ph ph-pencil"></i>
</button>
<button class="notes-action-btn notes-action-btn--danger notes-delete-btn" data-id="${note.id}" title="Löschen">
<i class="ph ph-trash"></i>
</button>
</div>
</div>
<!-- Notiztext -->
<p class="notes-card-text">${_esc(_truncate(note.text))}</p>
<!-- Micro-Badges -->
${microBadges.length ? `
<div class="notes-micro-badges">
${microBadges.map(b => `<span class="notes-micro-badge">${_esc(b)}</span>`).join('')}
</div>
` : ''}
<!-- Meta: Zeit + Ort -->
<div class="notes-card-meta">
<i class="ph ph-clock"></i>
${_esc(_formatTime(note.updated_at || note.created_at))}
${hasLocation ? `<i class="ph ph-map-pin"></i> ${_esc(note.location_name)}` : ''}
</div>
</div>
`;
}
// ----------------------------------------------------------
// Event-Binding
// ----------------------------------------------------------
function _bindEvents() {
// Filter-Chips
_container.querySelectorAll('.notes-chip').forEach(btn => {
btn.addEventListener('click', () => {
_filterType = btn.dataset.type;
_reload();
});
});
// Sortierung
_container.querySelectorAll('.notes-sort-btn').forEach(btn => {
btn.addEventListener('click', () => {
_sortMode = btn.dataset.sort;
_render(); // nur neu rendern, keine API-Last
});
});
// Suche (debounced)
const searchInput = _container.querySelector('#notes-search');
if (searchInput) {
searchInput.addEventListener('input', () => {
clearTimeout(_searchTimer);
_searchTimer = setTimeout(() => {
_searchQ = searchInput.value.trim();
_reload();
}, 300);
});
}
// KI-Toggle
const kiToggle = _container.querySelector('#notes-ki-toggle');
if (kiToggle) {
kiToggle.addEventListener('click', () => {
_kiOpen = !_kiOpen;
_render();
});
}
// KI-Analyse-Button
const kiBtn = _container.querySelector('#notes-ki-analyse-btn');
if (kiBtn) {
kiBtn.addEventListener('click', async () => {
_kiLoading = true;
_kiError = null;
_kiSuggestions = null;
_render();
try {
const res = await API.notes.analyse();
if (res && Array.isArray(res.suggestions)) {
_kiSuggestions = res.suggestions;
} else if (res && res.text) {
_kiSuggestions = res.text.split('\n').filter(Boolean);
} else {
_kiSuggestions = ['Keine Vorschläge verfügbar.'];
}
} catch (err) {
_kiError = err?.message || 'KI-Analyse nicht verfügbar.';
} finally {
_kiLoading = false;
_render();
}
});
}
// Edit-Buttons
_container.querySelectorAll('.notes-edit-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const note = _notes.find(n => n.id === parseInt(btn.dataset.id, 10));
if (note) _openEditModal(note);
});
});
// Delete-Buttons
_container.querySelectorAll('.notes-delete-btn').forEach(btn => {
btn.addEventListener('click', async e => {
e.stopPropagation();
const noteId = parseInt(btn.dataset.id, 10);
if (!window.confirm('Notiz wirklich löschen?')) return;
try {
await API.notes.delete(noteId);
_notes = _notes.filter(n => n.id !== noteId);
_render();
UI.toast.success('Notiz gelöscht.');
} catch (_) {
UI.toast.error('Löschen fehlgeschlagen.');
}
});
});
}
// ----------------------------------------------------------
// Laden + Re-Render
// ----------------------------------------------------------
async function _reload() {
_container.querySelector('.notes-list')?.classList.add('loading');
try {
_notes = await _load();
} catch (_) {
_notes = [];
}
_render();
}
// ----------------------------------------------------------
// Edit-Modal (Bottom-Sheet Stil)
// ----------------------------------------------------------
function _openEditModal(note) {
const meta = note.meta_json || {};
const rb = _rubrik(note.parent_type);
const modalId = 'notes-edit-modal';
document.getElementById(modalId)?.remove();
const overlay = document.createElement('div');
overlay.id = modalId;
overlay.style.cssText = `
position:fixed;inset:0;z-index:9999;
display:flex;align-items:flex-end;justify-content:center;
background:rgba(0,0,0,0.45);
`;
overlay.innerHTML = `
<div style="background:var(--c-surface);border-radius:var(--radius-lg) var(--radius-lg) 0 0;
width:100%;max-width:480px;max-height:85vh;overflow-y:auto;
padding:var(--space-5) var(--space-4) var(--space-6);box-shadow:0 -4px 24px rgba(0,0,0,0.15)">
<!-- Griff -->
<div style="width:40px;height:4px;background:var(--c-border);border-radius:2px;margin:0 auto var(--space-4)"></div>
<!-- Kopfzeile -->
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-4)">
<span style="display:inline-flex;align-items:center;gap:4px;font-size:var(--text-xs);
font-weight:var(--weight-semibold);padding:2px var(--space-2);border-radius:999px;
background:${rb.color}22;color:${rb.color}">
<i class="ph ph-${rb.icon}"></i> ${_esc(rb.label)}
</span>
<h3 style="font-size:var(--text-base);font-weight:var(--weight-semibold);color:var(--c-text);margin:0">
Notiz bearbeiten
</h3>
</div>
<div style="display:flex;flex-direction:column;gap:var(--space-4)">
<!-- Freitext -->
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">Text</label>
<textarea id="notes-edit-text" rows="5"
style="width:100%;padding:var(--space-3);border:1.5px solid var(--c-border);
border-radius:var(--radius-md);font-size:var(--text-sm);
font-family:var(--font-sans);background:var(--c-surface);
color:var(--c-text);resize:vertical;outline:none;line-height:1.5;
box-sizing:border-box">${_esc(note.text)}</textarea>
</div>
${note.parent_type === 'training_session' ? `
<!-- Bewertung -->
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">Bewertung</label>
<div style="display:flex;gap:var(--space-2)">
${[1,2,3,4,5].map(n => `
<button type="button" class="notes-pfote" data-val="${n}"
style="font-size:1.3rem;border:1.5px solid var(--c-border);border-radius:var(--radius-md);
padding:3px 9px;cursor:pointer;
background:${(meta.erfolgsquote||0)===n?'var(--c-primary-subtle)':'var(--c-surface-2)'};
border-color:${(meta.erfolgsquote||0)===n?'var(--c-primary)':'var(--c-border)'}">🐾</button>
`).join('')}
</div>
</div>
<!-- Umgebung -->
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">Umgebung</label>
<div style="display:flex;gap:var(--space-2)">
${[['🏠','zuhause'],['🌿','natur'],['🌆','stadt']].map(([emoji,val]) => `
<button type="button" class="notes-umgebung" data-val="${val}"
style="font-size:1.2rem;border:1.5px solid var(--c-border);border-radius:var(--radius-md);
padding:3px 10px;cursor:pointer;
background:${meta.umgebung===val?'var(--c-primary-subtle)':'var(--c-surface-2)'};
border-color:${meta.umgebung===val?'var(--c-primary)':'var(--c-border)'}">${emoji}</button>
`).join('')}
</div>
</div>
<!-- Stimmung -->
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">Stimmung des Hundes</label>
<div style="display:flex;gap:var(--space-2)">
${[['😊','super'],['😐','ok'],['😔','mude']].map(([emoji,val]) => `
<button type="button" class="notes-stimmung" data-val="${val}"
style="font-size:1.2rem;border:1.5px solid var(--c-border);border-radius:var(--radius-md);
padding:3px 10px;cursor:pointer;
background:${meta.hund_stimmung===val?'var(--c-primary-subtle)':'var(--c-surface-2)'};
border-color:${meta.hund_stimmung===val?'var(--c-primary)':'var(--c-border)'}">${emoji}</button>
`).join('')}
</div>
</div>
` : ''}
</div>
<!-- Buttons -->
<div style="display:flex;gap:var(--space-3);margin-top:var(--space-5)">
<button id="notes-edit-delete" type="button"
style="padding:var(--space-3) var(--space-4);border-radius:var(--radius-md);
border:1.5px solid var(--c-danger);background:none;
color:var(--c-danger);font-size:var(--text-sm);cursor:pointer">
Löschen
</button>
<button id="notes-edit-cancel" type="button"
style="flex:1;padding:var(--space-3);border-radius:var(--radius-md);
border:1.5px solid var(--c-border);background:none;
color:var(--c-text-secondary);font-size:var(--text-sm);cursor:pointer">
Abbrechen
</button>
<button id="notes-edit-save" type="button"
style="flex:2;padding:var(--space-3);border-radius:var(--radius-md);
border:none;background:var(--c-primary);
color:#fff;font-size:var(--text-sm);font-weight:var(--weight-semibold);cursor:pointer">
Speichern
</button>
</div>
</div>
`;
document.body.appendChild(overlay);
let selErfolgsquote = meta.erfolgsquote || null;
let selUmgebung = meta.umgebung || null;
let selStimmung = meta.hund_stimmung || null;
function _toggleBtn(group, val, getter, setter) {
overlay.querySelectorAll(`.notes-${group}`).forEach(b => {
const match = (group === 'pfote')
? parseInt(b.dataset.val, 10) === val
: b.dataset.val === val;
b.style.background = match ? 'var(--c-primary-subtle)' : 'var(--c-surface-2)';
b.style.borderColor = match ? 'var(--c-primary)' : 'var(--c-border)';
});
}
overlay.querySelectorAll('.notes-pfote').forEach(btn => {
btn.addEventListener('click', () => {
const v = parseInt(btn.dataset.val, 10);
selErfolgsquote = selErfolgsquote === v ? null : v;
_toggleBtn('pfote', selErfolgsquote, null, null);
});
});
overlay.querySelectorAll('.notes-umgebung').forEach(btn => {
btn.addEventListener('click', () => {
selUmgebung = selUmgebung === btn.dataset.val ? null : btn.dataset.val;
_toggleBtn('umgebung', selUmgebung, null, null);
});
});
overlay.querySelectorAll('.notes-stimmung').forEach(btn => {
btn.addEventListener('click', () => {
selStimmung = selStimmung === btn.dataset.val ? null : btn.dataset.val;
_toggleBtn('stimmung', selStimmung, null, null);
});
});
function _close() { overlay.remove(); }
overlay.addEventListener('click', e => { if (e.target === overlay) _close(); });
overlay.querySelector('#notes-edit-cancel').addEventListener('click', _close);
// Speichern
overlay.querySelector('#notes-edit-save').addEventListener('click', async () => {
const text = overlay.querySelector('#notes-edit-text').value.trim();
if (!text) { UI.toast.warning('Notiz darf nicht leer sein.'); return; }
const saveBtn = overlay.querySelector('#notes-edit-save');
saveBtn.disabled = true;
saveBtn.textContent = 'Speichern…';
const metaObj = {};
if (selErfolgsquote) metaObj.erfolgsquote = selErfolgsquote;
if (selUmgebung) metaObj.umgebung = selUmgebung;
if (selStimmung) metaObj.hund_stimmung = selStimmung;
try {
const updated = await API.notes.update(note.id, {
text,
meta_json: Object.keys(metaObj).length > 0 ? metaObj : null,
});
const idx = _notes.findIndex(n => n.id === note.id);
if (idx >= 0) _notes[idx] = updated;
_render();
_close();
UI.toast.success('Notiz aktualisiert.');
} catch (_) {
saveBtn.disabled = false;
saveBtn.textContent = 'Speichern';
UI.toast.error('Speichern fehlgeschlagen.');
}
});
// Löschen
overlay.querySelector('#notes-edit-delete').addEventListener('click', async () => {
if (!window.confirm('Notiz wirklich löschen?')) return;
try {
await API.notes.delete(note.id);
_notes = _notes.filter(n => n.id !== note.id);
_render();
_close();
UI.toast.success('Notiz gelöscht.');
} catch (_) {
UI.toast.error('Löschen fehlgeschlagen.');
}
});
}
// ----------------------------------------------------------
// INIT / REFRESH
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
// Zustand zurücksetzen
_filterType = '';
_sortMode = 'newest';
_searchQ = '';
_kiOpen = false;
_kiLoading = false;
_kiSuggestions = null;
_kiError = null;
_notes = [];
_container.innerHTML = UI.skeleton(3);
try {
_notes = await _load();
} catch (_) {
_notes = [];
}
_render();
}
async function refresh() {
if (!_container) return;
_container.innerHTML = UI.skeleton(3);
try {
_notes = await _load();
} catch (_) {
_notes = [];
}
_render();
}
return { init, refresh };
})();

View file

@ -126,7 +126,7 @@ window.Page_onboarding = (() => {
<div style="width:36px;height:36px;border-radius:var(--radius-md);
background:var(--c-primary-subtle);flex-shrink:0;
display:flex;align-items:center;justify-content:center">
<svg style="width:18px;height:18px;color:var(--c-primary)" aria-hidden="true">
<svg style="fill:currentColor;width:18px;height:18px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#${icon}"></use>
</svg>
</div>
@ -167,7 +167,7 @@ window.Page_onboarding = (() => {
<div style="width:64px;height:64px;border-radius:50%;
background:var(--c-primary-subtle);margin:0 auto var(--space-4);
display:flex;align-items:center;justify-content:center">
<svg style="width:32px;height:32px;color:var(--c-primary)" aria-hidden="true">
<svg style="fill:currentColor;width:32px;height:32px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#dog"></use>
</svg>
</div>
@ -262,7 +262,7 @@ window.Page_onboarding = (() => {
<div style="width:80px;height:80px;border-radius:50%;
background:var(--c-success-subtle,#dcfce7);margin:0 auto var(--space-4);
display:flex;align-items:center;justify-content:center">
<svg style="width:40px;height:40px;color:var(--c-success)" aria-hidden="true">
<svg style="fill:currentColor;width:40px;height:40px;color:var(--c-success)" aria-hidden="true">
<use href="/icons/phosphor.svg#check-circle"></use>
</svg>
</div>

View file

@ -1805,6 +1805,7 @@ window.Page_routes = (() => {
${_actionBtn('rd-gpx', 'download-simple', 'GPX')}
${_actionBtn('rd-share', 'arrow-square-out', 'Teilen')}
${_actionBtn('rd-navi', 'map-pin', 'Navi')}
${_appState.user ? _actionBtn('rd-note', 'note-pencil', 'Notiz') : ''}
</div>
${ownerRow}
<button type="button" class="btn btn-primary w-full" id="rd-close">Schließen</button>
@ -1920,6 +1921,12 @@ window.Page_routes = (() => {
} catch (err) { UI.toast.error(err.message); }
});
// Notiz-Button
document.getElementById('rd-note')?.addEventListener('click', () => {
const label = route.name || (route.distanz_km ? route.distanz_km.toFixed(1) + ' km' : 'Route');
_openNoteModal('route', route.id, label, null);
});
// Mini-Map
let _detailMap = null;
setTimeout(() => {
@ -2504,6 +2511,59 @@ window.Page_routes = (() => {
});
}
// ----------------------------------------------------------
// Notiz-Modal (Custom DOM — kein UI.modal um Konflikte zu vermeiden)
// ----------------------------------------------------------
async function _openNoteModal(parentType, parentId, parentLabel, locationName) {
let existingNote = null;
try { existingNote = await API.notes.get(parentType, String(parentId)); } catch {}
const ovl = document.createElement('div');
ovl.style.cssText = 'position:fixed;inset:0;z-index:1200;background:rgba(0,0,0,0.55);display:flex;align-items:flex-end;justify-content:center';
ovl.innerHTML = `
<div style="width:100%;max-width:600px;background:var(--c-surface);border-radius:var(--radius-lg) var(--radius-lg) 0 0;
padding:var(--space-4);box-sizing:border-box;max-height:80vh;display:flex;flex-direction:column">
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)">
<svg class="ph-icon" aria-hidden="true" style="color:var(--c-primary)"><use href="/icons/phosphor.svg#note-pencil"></use></svg>
<span style="font-weight:600;flex:1"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz ${UI.escape(parentLabel)}</span>
<button id="rk-note-close" style="background:none;border:none;cursor:pointer;color:var(--c-text-muted);padding:4px">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
</div>
<textarea id="rk-note-text" rows="5"
style="width:100%;box-sizing:border-box;padding:var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
font-size:var(--text-sm);font-family:inherit;
background:var(--c-bg);color:var(--c-text);resize:vertical;flex:1"
placeholder="Deine Notiz zu dieser Route…">${UI.escape(existingNote?.text || '')}</textarea>
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-3)">
<button id="rk-note-cancel" class="btn btn-secondary flex-1">Abbrechen</button>
<button id="rk-note-save" class="btn btn-primary flex-1">Speichern</button>
</div>
</div>
`;
document.body.appendChild(ovl);
const close = () => ovl.remove();
ovl.querySelector('#rk-note-close')?.addEventListener('click', close);
ovl.querySelector('#rk-note-cancel')?.addEventListener('click', close);
ovl.addEventListener('click', e => { if (e.target === ovl) close(); });
ovl.querySelector('#rk-note-save')?.addEventListener('click', async () => {
const text = ovl.querySelector('#rk-note-text')?.value?.trim() || '';
const payload = { text, parent_label: parentLabel, location_name: locationName || null };
try {
if (existingNote?.id) {
await API.notes.update(existingNote.id, 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.'); }
});
}
return { init, refresh, onDogChange };
})();

View file

@ -266,6 +266,30 @@ window.Page_settings = (() => {
</select>
</div>
<!-- KI-Notiz-Assistent -->
<div style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-4)">
<svg class="ph-icon" aria-hidden="true" style="width:1.25rem;height:1.25rem"><use href="/icons/phosphor.svg#brain"></use></svg>
<div style="flex:1">
<div style="font-weight:500">KI-Notiz-Assistent</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
Erkennt Muster in deinen Notizen und macht Vorschläge
</div>
</div>
<label style="position:relative;display:inline-block;width:44px;height:24px;flex-shrink:0">
<input type="checkbox" id="toggle-notes-ki"
style="opacity:0;width:0;height:0;position:absolute"
${u.notes_ki_enabled ? 'checked' : ''}>
<span style="position:absolute;cursor:pointer;inset:0;border-radius:12px;
background:var(--c-border);transition:.2s"
id="toggle-notes-ki-track"></span>
<span id="toggle-notes-ki-thumb"
style="position:absolute;top:2px;left:${u.notes_ki_enabled ? '22px' : '2px'};
width:20px;height:20px;border-radius:50%;
background:#fff;transition:.2s;
box-shadow:0 1px 3px rgba(0,0,0,.3)"></span>
</label>
</div>
</div>
</div>
@ -635,6 +659,25 @@ window.Page_settings = (() => {
: 'Pocket-Modus deaktiviert.');
});
document.getElementById('toggle-notes-ki')?.addEventListener('change', async e => {
const enabled = e.target.checked;
const track = document.getElementById('toggle-notes-ki-track');
const thumb = document.getElementById('toggle-notes-ki-thumb');
if (track) track.style.background = enabled ? 'var(--c-primary)' : 'var(--c-border)';
if (thumb) thumb.style.left = enabled ? '22px' : '2px';
try {
await API.patch('/profile', { notes_ki_enabled: enabled ? 1 : 0 });
_appState.user.notes_ki_enabled = enabled ? 1 : 0;
UI.toast.success(enabled ? 'KI-Notiz-Assistent aktiviert.' : 'KI-Notiz-Assistent deaktiviert.');
} catch (err) {
UI.toast.error(err?.message || 'Einstellung konnte nicht gespeichert werden.');
// Revert UI
e.target.checked = !enabled;
if (track) track.style.background = !enabled ? 'var(--c-primary)' : 'var(--c-border)';
if (thumb) thumb.style.left = !enabled ? '22px' : '2px';
}
});
_loadReferral();
}

View file

@ -136,6 +136,12 @@ window.Page_sitting = (() => {
<div class="sitting-card-side">
<div class="sitting-price">${s.preis_pro_tag > 0 ? s.preis_pro_tag.toFixed(0) + ' €/Tag' : 'Preis anfragen'}</div>
<div class="sitting-dogs">max. ${s.max_hunde} Hund${s.max_hunde !== 1 ? 'e' : ''}</div>
${_state.user ? `<button class="btn-icon sit-note-btn"
data-sit-note-id="${s.id}"
data-sit-note-label="${UI.escHtml(s.sitter_name + ' ' + (s.datum || ''))}"
title="Notiz" style="color:var(--c-text-muted);margin-top:var(--space-1)"
onclick="event.stopPropagation()">
${UI.icon('note-pencil')}</button>` : ''}
</div>
</div>
`;
@ -704,6 +710,19 @@ window.Page_sitting = (() => {
return;
}
// Notiz-Button auf Sitter-Karte
const noteBtn = e.target.closest('.sit-note-btn');
if (noteBtn) {
e.stopPropagation();
_openNoteModal(
'sitting',
parseInt(noteBtn.dataset.sitNoteId),
noteBtn.dataset.sitNoteLabel,
null
);
return;
}
// Sitter-Karte
const sitterCard = e.target.closest('[data-sit-id]');
if (sitterCard && !e.target.closest('button')) {
@ -741,6 +760,59 @@ window.Page_sitting = (() => {
} catch (e) { UI.toast(e.message, 'error'); }
}
// ----------------------------------------------------------
// Notiz-Modal (Custom DOM — kein UI.modal um Konflikte zu vermeiden)
// ----------------------------------------------------------
async function _openNoteModal(parentType, parentId, parentLabel, locationName) {
let existingNote = null;
try { existingNote = await API.notes.get(parentType, String(parentId)); } catch {}
const ovl = document.createElement('div');
ovl.style.cssText = 'position:fixed;inset:0;z-index:1200;background:rgba(0,0,0,0.55);display:flex;align-items:flex-end;justify-content:center';
ovl.innerHTML = `
<div style="width:100%;max-width:600px;background:var(--c-surface);border-radius:var(--radius-lg) var(--radius-lg) 0 0;
padding:var(--space-4);box-sizing:border-box;max-height:80vh;display:flex;flex-direction:column">
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)">
<svg class="ph-icon" aria-hidden="true" style="color:var(--c-primary)"><use href="/icons/phosphor.svg#note-pencil"></use></svg>
<span style="font-weight:600;flex:1"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz ${UI.escape(parentLabel)}</span>
<button id="sit-note-close" style="background:none;border:none;cursor:pointer;color:var(--c-text-muted);padding:4px">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
</div>
<textarea id="sit-note-text" rows="5"
style="width:100%;box-sizing:border-box;padding:var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
font-size:var(--text-sm);font-family:inherit;
background:var(--c-bg);color:var(--c-text);resize:vertical;flex:1"
placeholder="Deine Notiz zu diesem Sitter…">${UI.escape(existingNote?.text || '')}</textarea>
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-3)">
<button id="sit-note-cancel" class="btn btn-secondary flex-1">Abbrechen</button>
<button id="sit-note-save" class="btn btn-primary flex-1">Speichern</button>
</div>
</div>
`;
document.body.appendChild(ovl);
const close = () => ovl.remove();
ovl.querySelector('#sit-note-close')?.addEventListener('click', close);
ovl.querySelector('#sit-note-cancel')?.addEventListener('click', close);
ovl.addEventListener('click', e => { if (e.target === ovl) close(); });
ovl.querySelector('#sit-note-save')?.addEventListener('click', async () => {
const text = ovl.querySelector('#sit-note-text')?.value?.trim() || '';
const payload = { text, parent_label: parentLabel, location_name: locationName || null };
try {
if (existingNote?.id) {
await API.notes.update(existingNote.id, 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.'); }
});
}
return { init, refresh };
})();

View file

@ -895,6 +895,7 @@ window.Page_uebungen = (() => {
_bindAccordions();
_bindStatusButtons();
_bindLogButtons();
_bindNotizButtons();
if (_activeTab === 'ki-trainer') _loadKiTrainerFeedback();
}
@ -965,6 +966,19 @@ window.Page_uebungen = (() => {
Einheit
</button>
${_sessionStatsChip(_activeTab, u.name)}
<button class="ueb-notiz-btn"
data-tab="${_esc(_activeTab)}"
data-name="${_esc(u.name)}"
title="Notiz hinzufügen"
style="background:none;border:1px solid var(--c-border);cursor:pointer;
padding:3px 7px;border-radius:var(--radius-sm);
display:flex;align-items:center;gap:3px;
font-size:var(--text-xs);color:var(--c-text-secondary)">
<svg class="ph-icon" style="width:13px;height:13px;flex-shrink:0" aria-hidden="true">
<use href="/icons/phosphor.svg#note-pencil"></use>
</svg>
Notiz
</button>
<button class="ueb-status-btn"
data-tab="${_esc(_activeTab)}"
data-name="${_esc(u.name)}"
@ -1006,7 +1020,7 @@ window.Page_uebungen = (() => {
background:#78350f22;border:1px solid #d9770644;border-radius:var(--radius-sm);
font-size:var(--text-xs);color:var(--c-text);line-height:1.4;
display:flex;align-items:flex-start;gap:var(--space-2)">
<svg class="ph-icon" style="width:13px;height:13px;flex-shrink:0;margin-top:1px;color:#f59e0b" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg>
<svg class="ph-icon" style="width:13px;height:13px;flex-shrink:0;margin-top:1px;color:var(--c-warning)" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg>
<span>${_esc(u.hinweis)}</span>
</div>
` : ''}
@ -1039,7 +1053,7 @@ window.Page_uebungen = (() => {
${u.fehler.length ? `
<p style="font-size:var(--text-xs);font-weight:var(--weight-semibold);
color:var(--c-text-secondary);margin-bottom:var(--space-2);text-transform:uppercase;letter-spacing:0.05em">
<svg class="ph-icon" style="width:12px;height:12px;color:#f59e0b" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg>
<svg class="ph-icon" style="width:12px;height:12px;color:var(--c-warning)" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg>
Häufige Fehler
</p>
<ul style="margin:0 0 var(--space-4);padding-left:var(--space-5);display:flex;flex-direction:column;gap:var(--space-2)">
@ -1100,6 +1114,252 @@ window.Page_uebungen = (() => {
});
}
function _bindNotizButtons() {
_container.querySelectorAll('.ueb-notiz-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const exerciseId = `${btn.dataset.tab}_${btn.dataset.name.replace(/[\s/]+/g, '_')}`;
_openNotizModal(exerciseId, btn.dataset.name, btn);
});
});
}
async function _openNotizModal(exerciseId, exerciseName, triggerBtn) {
const modalId = 'ueb-notiz-modal';
document.getElementById(modalId)?.remove();
// Lade bestehende Notiz
let existingNote = null;
if (_appState?.user) {
try {
const notes = await API.notes.get('training_session', exerciseId.length > 0 ? exerciseId : 0);
if (notes && notes.length > 0) existingNote = notes[0];
} catch (_) {}
}
const overlay = document.createElement('div');
overlay.id = modalId;
overlay.style.cssText = `
position:fixed;inset:0;z-index:9999;
display:flex;align-items:flex-end;justify-content:center;
background:rgba(0,0,0,0.45);
`;
const noteText = existingNote?.text || '';
const meta = existingNote?.meta_json || {};
const currentErfolgsquote = meta.erfolgsquote || null;
const currentUmgebung = meta.umgebung || null;
const currentStimmung = meta.hund_stimmung || null;
overlay.innerHTML = `
<div style="background:var(--c-surface);border-radius:var(--radius-lg) var(--radius-lg) 0 0;
width:100%;max-width:480px;max-height:85vh;overflow-y:auto;
padding:var(--space-5) var(--space-4) var(--space-6);box-shadow:0 -4px 24px rgba(0,0,0,0.15)">
<div style="width:40px;height:4px;background:var(--c-border);border-radius:2px;margin:0 auto var(--space-4)"></div>
<h3 style="font-size:var(--text-base);font-weight:var(--weight-semibold);color:var(--c-text);
margin:0 0 var(--space-4);text-align:center">
Notiz: ${_esc(exerciseName)}
</h3>
<div style="display:flex;flex-direction:column;gap:var(--space-4)">
<!-- Freitext -->
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">Notiz</label>
<textarea id="ueb-notiz-text" rows="3"
placeholder="Was ist dir aufgefallen? Tipps für nächstes Mal…"
style="width:100%;padding:var(--space-3);border:1.5px solid var(--c-border);
border-radius:var(--radius-md);font-size:var(--text-sm);
font-family:var(--font-sans);background:var(--c-surface);
color:var(--c-text);resize:vertical;outline:none;
line-height:1.5">${_esc(noteText)}</textarea>
</div>
<!-- Erfolgsquote -->
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">Bewertung (optional)</label>
<div style="display:flex;gap:var(--space-2)">
${[1,2,3,4,5].map(n => `
<button type="button" class="ueb-notiz-pfote" data-val="${n}"
style="font-size:1.4rem;border:1.5px solid var(--c-border);
border-radius:var(--radius-md);padding:4px 10px;cursor:pointer;
background:${currentErfolgsquote === n ? 'var(--c-primary-subtle)' : 'var(--c-surface-2)'};
border-color:${currentErfolgsquote === n ? 'var(--c-primary)' : 'var(--c-border)'};
transition:all 0.15s">🐾</button>
`).join('')}
</div>
</div>
<!-- Umgebung -->
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">Umgebung (optional)</label>
<div style="display:flex;gap:var(--space-2)">
${[['🏠','zuhause'],['🌿','natur'],['🌆','stadt']].map(([emoji, val]) => `
<button type="button" class="ueb-notiz-umgebung" data-val="${val}"
style="font-size:1.2rem;border:1.5px solid var(--c-border);
border-radius:var(--radius-md);padding:4px 12px;cursor:pointer;
background:${currentUmgebung === val ? 'var(--c-primary-subtle)' : 'var(--c-surface-2)'};
border-color:${currentUmgebung === val ? 'var(--c-primary)' : 'var(--c-border)'};
transition:all 0.15s">${emoji}</button>
`).join('')}
</div>
</div>
<!-- Hund-Stimmung -->
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">Stimmung des Hundes (optional)</label>
<div style="display:flex;gap:var(--space-2)">
${[['😊','super'],['😐','ok'],['😔','mude']].map(([emoji, val]) => `
<button type="button" class="ueb-notiz-stimmung" data-val="${val}"
style="font-size:1.2rem;border:1.5px solid var(--c-border);
border-radius:var(--radius-md);padding:4px 12px;cursor:pointer;
background:${currentStimmung === val ? 'var(--c-primary-subtle)' : 'var(--c-surface-2)'};
border-color:${currentStimmung === val ? 'var(--c-primary)' : 'var(--c-border)'};
transition:all 0.15s">${emoji}</button>
`).join('')}
</div>
</div>
</div>
<!-- Buttons -->
<div style="display:flex;gap:var(--space-3);margin-top:var(--space-5)">
${existingNote ? `
<button id="ueb-notiz-delete" type="button"
style="padding:var(--space-3) var(--space-4);border-radius:var(--radius-md);
border:1.5px solid var(--c-danger);background:none;
color:var(--c-danger);font-size:var(--text-sm);cursor:pointer">
Löschen
</button>
` : ''}
<button id="ueb-notiz-cancel" type="button"
style="flex:1;padding:var(--space-3);border-radius:var(--radius-md);
border:1.5px solid var(--c-border);background:none;
color:var(--c-text-secondary);font-size:var(--text-sm);cursor:pointer">
Abbrechen
</button>
<button id="ueb-notiz-save" type="button"
style="flex:2;padding:var(--space-3);border-radius:var(--radius-md);
border:none;background:var(--c-primary);
color:#fff;font-size:var(--text-sm);font-weight:var(--weight-semibold);cursor:pointer">
${existingNote ? 'Aktualisieren' : 'Speichern'}
</button>
</div>
</div>
`;
document.body.appendChild(overlay);
// State
let selectedErfolgsquote = currentErfolgsquote;
let selectedUmgebung = currentUmgebung;
let selectedStimmung = currentStimmung;
// Pfoten-Buttons
overlay.querySelectorAll('.ueb-notiz-pfote').forEach(btn => {
btn.addEventListener('click', () => {
const val = parseInt(btn.dataset.val, 10);
selectedErfolgsquote = selectedErfolgsquote === val ? null : val;
overlay.querySelectorAll('.ueb-notiz-pfote').forEach(b => {
const active = parseInt(b.dataset.val, 10) === selectedErfolgsquote;
b.style.background = active ? 'var(--c-primary-subtle)' : 'var(--c-surface-2)';
b.style.borderColor = active ? 'var(--c-primary)' : 'var(--c-border)';
});
});
});
// Umgebung-Buttons
overlay.querySelectorAll('.ueb-notiz-umgebung').forEach(btn => {
btn.addEventListener('click', () => {
selectedUmgebung = selectedUmgebung === btn.dataset.val ? null : btn.dataset.val;
overlay.querySelectorAll('.ueb-notiz-umgebung').forEach(b => {
const active = b.dataset.val === selectedUmgebung;
b.style.background = active ? 'var(--c-primary-subtle)' : 'var(--c-surface-2)';
b.style.borderColor = active ? 'var(--c-primary)' : 'var(--c-border)';
});
});
});
// Stimmung-Buttons
overlay.querySelectorAll('.ueb-notiz-stimmung').forEach(btn => {
btn.addEventListener('click', () => {
selectedStimmung = selectedStimmung === btn.dataset.val ? null : btn.dataset.val;
overlay.querySelectorAll('.ueb-notiz-stimmung').forEach(b => {
const active = b.dataset.val === selectedStimmung;
b.style.background = active ? 'var(--c-primary-subtle)' : 'var(--c-surface-2)';
b.style.borderColor = active ? 'var(--c-primary)' : 'var(--c-border)';
});
});
});
function _closeNotizModal() {
overlay.remove();
}
overlay.addEventListener('click', e => { if (e.target === overlay) _closeNotizModal(); });
overlay.querySelector('#ueb-notiz-cancel').addEventListener('click', _closeNotizModal);
// Speichern
overlay.querySelector('#ueb-notiz-save').addEventListener('click', async () => {
const text = overlay.querySelector('#ueb-notiz-text').value.trim();
if (!text) { UI.toast.warning('Bitte gib eine Notiz ein.'); return; }
const saveBtn = overlay.querySelector('#ueb-notiz-save');
saveBtn.disabled = true;
saveBtn.textContent = 'Speichern…';
const meta = {};
if (selectedErfolgsquote) meta.erfolgsquote = selectedErfolgsquote;
if (selectedUmgebung) meta.umgebung = selectedUmgebung;
if (selectedStimmung) meta.hund_stimmung = selectedStimmung;
const payload = {
text,
meta_json: Object.keys(meta).length > 0 ? meta : null,
};
try {
if (existingNote) {
await API.notes.update(existingNote.id, payload);
} else {
await API.notes.create('training_session', exerciseId, payload);
}
_closeNotizModal();
UI.toast.success('Notiz gespeichert.');
// Notiz-Button leicht hervorheben
if (triggerBtn) {
triggerBtn.style.borderColor = 'var(--c-primary)';
triggerBtn.style.color = 'var(--c-primary)';
}
} catch (err) {
saveBtn.disabled = false;
saveBtn.textContent = existingNote ? 'Aktualisieren' : 'Speichern';
UI.toast.error('Speichern fehlgeschlagen.');
}
});
// Löschen
overlay.querySelector('#ueb-notiz-delete')?.addEventListener('click', async () => {
if (!existingNote) return;
try {
await API.notes.delete(existingNote.id);
_closeNotizModal();
UI.toast.success('Notiz gelöscht.');
if (triggerBtn) {
triggerBtn.style.borderColor = '';
triggerBtn.style.color = '';
}
} catch (_) {
UI.toast.error('Löschen fehlgeschlagen.');
}
});
}
function _openLogModal(tab, exerciseName, initialReps) {
// Build the modal HTML
const modalId = 'ueb-log-modal';

View file

@ -192,6 +192,18 @@ window.Page_walks = (() => {
el.querySelectorAll('.walks-card').forEach(card => {
card.addEventListener('click', () => _openDetail(parseInt(card.dataset.id)));
});
el.querySelectorAll('.wk-note-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
_openNoteModal(
'walk',
parseInt(btn.dataset.wkNoteId),
btn.dataset.wkNoteLabel,
btn.dataset.wkNoteOrt || null
);
});
});
}
function _walkCardHTML(w) {
@ -217,7 +229,16 @@ window.Page_walks = (() => {
${isOwn ? '<span class="walks-badge walks-badge--own">Mein Treffen</span>' : ''}
</div>
</div>
<div class="walks-card-arrow"></div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:var(--space-1)">
<div class="walks-card-arrow"></div>
${_appState.user ? `<button class="btn-icon wk-note-btn"
data-wk-note-id="${w.id}"
data-wk-note-label="${UI.escape(w.titel + ' ' + w.datum)}"
data-wk-note-ort="${UI.escape(w.ort_name || '')}"
title="Notiz" style="color:var(--c-text-muted);font-size:var(--text-xs)"
onclick="event.stopPropagation()">
${UI.icon('note-pencil')}</button>` : ''}
</div>
</div>`;
}
@ -964,6 +985,59 @@ window.Page_walks = (() => {
});
}
// ----------------------------------------------------------
// Notiz-Modal (Custom DOM — kein UI.modal um Konflikte zu vermeiden)
// ----------------------------------------------------------
async function _openNoteModal(parentType, parentId, parentLabel, locationName) {
let existingNote = null;
try { existingNote = await API.notes.get(parentType, String(parentId)); } catch {}
const ovl = document.createElement('div');
ovl.style.cssText = 'position:fixed;inset:0;z-index:1200;background:rgba(0,0,0,0.55);display:flex;align-items:flex-end;justify-content:center';
ovl.innerHTML = `
<div style="width:100%;max-width:600px;background:var(--c-surface);border-radius:var(--radius-lg) var(--radius-lg) 0 0;
padding:var(--space-4);box-sizing:border-box;max-height:80vh;display:flex;flex-direction:column">
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)">
<svg class="ph-icon" aria-hidden="true" style="color:var(--c-primary)"><use href="/icons/phosphor.svg#note-pencil"></use></svg>
<span style="font-weight:600;flex:1"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz ${UI.escape(parentLabel)}</span>
<button id="wk-note-close" style="background:none;border:none;cursor:pointer;color:var(--c-text-muted);padding:4px">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
</div>
<textarea id="wk-note-text" rows="5"
style="width:100%;box-sizing:border-box;padding:var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
font-size:var(--text-sm);font-family:inherit;
background:var(--c-bg);color:var(--c-text);resize:vertical;flex:1"
placeholder="Deine Notiz zu diesem Gassi-Treffen…">${UI.escape(existingNote?.text || '')}</textarea>
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-3)">
<button id="wk-note-cancel" class="btn btn-secondary flex-1">Abbrechen</button>
<button id="wk-note-save" class="btn btn-primary flex-1">Speichern</button>
</div>
</div>
`;
document.body.appendChild(ovl);
const close = () => ovl.remove();
ovl.querySelector('#wk-note-close')?.addEventListener('click', close);
ovl.querySelector('#wk-note-cancel')?.addEventListener('click', close);
ovl.addEventListener('click', e => { if (e.target === ovl) close(); });
ovl.querySelector('#wk-note-save')?.addEventListener('click', async () => {
const text = ovl.querySelector('#wk-note-text')?.value?.trim() || '';
const payload = { text, parent_label: parentLabel, location_name: locationName || null };
try {
if (existingNote?.id) {
await API.notes.update(existingNote.id, 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.'); }
});
}
return { init, refresh, onDogChange, openNew, openDetail: _openDetail };
})();

View file

@ -53,7 +53,7 @@ window.Page_welcome = (() => {
style="width:36px;height:36px;border-radius:var(--radius-md);
background:var(--c-primary);flex-shrink:0;
display:flex;align-items:center;justify-content:center">
<svg style="width:20px;height:20px;color:#fff" aria-hidden="true">
<svg style="fill:currentColor;width:20px;height:20px;color:#fff" aria-hidden="true">
<use href="/icons/phosphor.svg#list"></use>
</svg>
</div>
@ -237,7 +237,7 @@ window.Page_welcome = (() => {
<div style="width:34px;height:34px;border-radius:var(--radius-md);
background:var(--c-primary-subtle);flex-shrink:0;
display:flex;align-items:center;justify-content:center">
<svg style="width:18px;height:18px;color:var(--c-primary)" aria-hidden="true">
<svg style="fill:currentColor;width:18px;height:18px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#${icon}"></use>
</svg>
</div>