Feature: 3 Community-Features — Foto-Challenge, Stamm-Gassis, Rassen-Chip (SW by-v700)

- Foto-Challenge der Woche: DB-Tabellen, routes/challenges.py (current/submit/vote/winners),
  Scheduler-Job jeden Montag 08:00, walks.js Challenge-Tab mit Banner, Galerie, Voting-Herz
- Gassi-Zeiten-Pool: DB-Tabelle gassi_zeiten, routes/gassi_zeiten.py (CRUD + Umkreis),
  walks.js Stamm-Gassis-Tab mit Karten, Wochentag-Selector, Mitmachen→Chat
- Rassen-Treffen-Chip: GET /api/friends/same-breed, dog-profile.js zeigt Chip
  wenn andere User gleiche Rasse haben, Klick → Forum mit Rassen-Suche vorausgefüllt
This commit is contained in:
rene 2026-05-04 21:09:35 +02:00
parent d6206d378e
commit aa4849d947
10 changed files with 1322 additions and 22 deletions

View file

@ -7304,6 +7304,20 @@ svg.empty-state-icon {
color: var(--c-text-secondary);
line-height: 1.2;
}
.exp-kachel-jahr {
font-size: 9px;
color: var(--c-text-muted);
margin-top: 2px;
line-height: 1.2;
}
.exp-kachel-add {
display: flex;
align-items: center;
gap: 2px;
font-size: 10px;
color: var(--c-text-muted);
margin-top: 3px;
}
/* ---- Sektion-Block (Verlauf etc.) ---- */
.exp-section {
@ -7479,6 +7493,36 @@ svg.empty-state-icon {
border-radius: 999px;
padding: 1px 6px;
}
.exp-dog-selector {
display: flex;
gap: 8px;
padding: 10px 16px 4px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.exp-dog-selector::-webkit-scrollbar { display: none; }
.exp-dog-pill {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 5px 14px;
border-radius: 999px;
border: 1px solid var(--c-border);
background: var(--c-bg-card);
color: var(--c-text-secondary);
font-size: var(--text-sm);
font-weight: var(--weight-medium);
cursor: pointer;
white-space: nowrap;
transition: background .15s, color .15s, border-color .15s;
}
.exp-dog-pill.active {
background: var(--c-primary);
color: #fff;
border-color: var(--c-primary);
}
/* Rechte Spalte: Betrag + Löschen-Icon */
.exp-entry-right {
@ -8133,3 +8177,189 @@ svg.empty-state-icon {
0%, 100% { opacity: 0.5; }
50% { opacity: 0.25; }
}
/* ── COMMUNITY-FEATURES ──────────────────────────────────── */
/* Walks-Tab-Bar */
.walks-tab-panel { display: flex; flex-direction: column; min-height: 0; flex: 1; }
/* Foto-Challenge */
.challenge-banner {
background: linear-gradient(135deg, var(--c-amber, #f59e0b), var(--c-primary, #C4843A));
border-radius: var(--radius-lg);
margin: var(--space-4);
overflow: hidden;
}
.challenge-banner-inner {
padding: var(--space-5) var(--space-4);
color: #fff;
}
.challenge-thema {
font-size: var(--text-xl);
font-weight: var(--weight-bold);
line-height: 1.2;
margin-bottom: var(--space-2);
}
.challenge-meta {
font-size: var(--text-sm);
opacity: 0.88;
}
.challenge-gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: var(--space-3);
padding: 0 var(--space-4) var(--space-6);
}
.challenge-sub-card {
background: var(--c-surface);
border-radius: var(--radius-md);
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.challenge-sub-card img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
display: block;
cursor: pointer;
}
.challenge-sub-info {
padding: var(--space-2);
}
.challenge-sub-user {
font-size: var(--text-xs);
color: var(--c-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 2px;
}
.challenge-sub-caption {
font-size: var(--text-xs);
color: var(--c-text);
margin-bottom: var(--space-1);
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.challenge-vote-btn {
border: none;
background: transparent;
color: var(--c-text-secondary);
font-size: var(--text-xs);
cursor: pointer;
padding: 2px 6px;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
gap: 3px;
}
.challenge-vote-btn.voted {
color: var(--c-danger, #ef4444);
}
.challenge-winners { border-top: 1px solid var(--c-border); }
.challenge-winners-row {
display: flex;
gap: var(--space-3);
overflow-x: auto;
padding: var(--space-2) var(--space-4) var(--space-3);
scroll-snap-type: x mandatory;
}
.challenge-winner-chip {
display: flex;
align-items: center;
gap: var(--space-2);
background: var(--c-surface-alt, #fdf6ef);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3);
min-width: 160px;
flex-shrink: 0;
scroll-snap-align: start;
}
.challenge-winner-chip img {
width: 40px;
height: 40px;
border-radius: var(--radius-full);
object-fit: cover;
flex-shrink: 0;
}
/* Wochentag-Selector */
.wd-selector {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.wd-btn {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
padding: 4px 10px;
border: 1.5px solid var(--c-border);
border-radius: var(--radius-full);
font-size: var(--text-sm);
user-select: none;
transition: background .15s, border-color .15s;
}
.wd-btn input { display: none; }
.wd-btn:has(input:checked) {
background: var(--c-primary, #C4843A);
border-color: var(--c-primary, #C4843A);
color: #fff;
}
/* Gassi-Zeit-Karten */
.gassi-zeit-card {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--c-border);
background: var(--c-surface);
}
.gz-avatar {
width: 48px;
height: 48px;
border-radius: var(--radius-full);
overflow: hidden;
flex-shrink: 0;
background: var(--c-surface-alt);
display: flex;
align-items: center;
justify-content: center;
}
.gz-avatar img { width: 100%; height: 100%; object-fit: cover; }
.gz-avatar-placeholder { font-size: 1.5rem; color: var(--c-text-secondary); }
.gz-body { flex: 1; min-width: 0; }
.gz-name {
font-weight: var(--weight-semibold);
font-size: var(--text-sm);
display: flex;
align-items: center;
gap: var(--space-1);
flex-wrap: wrap;
}
.gz-meta { font-size: var(--text-xs); color: var(--c-text-secondary); margin-top: 2px; }
.gz-notiz { font-size: var(--text-xs); color: var(--c-text-secondary); margin-top: 2px; font-style: italic; }
.gz-actions { display: flex; gap: var(--space-1); flex-shrink: 0; }
/* Rassen-Community-Chip */
.breed-community-chip {
display: inline-flex;
align-items: center;
gap: var(--space-1);
background: var(--c-surface-alt, #fdf6ef);
border: 1.5px solid var(--c-amber, #f59e0b);
border-radius: var(--radius-full);
padding: 6px 16px;
font-size: var(--text-sm);
font-weight: var(--weight-medium);
color: var(--c-text);
cursor: pointer;
transition: background .15s;
}
.breed-community-chip:hover, .breed-community-chip:active {
background: #fff3e0;
}

View file

@ -97,8 +97,11 @@ window.Page_dog_profile = (() => {
<h2 style="font-size:var(--text-2xl);font-weight:700;
color:var(--c-text);margin:0 0 var(--space-1)">${_esc(dog.name)}</h2>
${dog.rasse
? `<p style="color:var(--c-text-secondary);margin:0 0 var(--space-5)">${_esc(dog.rasse)}</p>`
: `<p style="margin:0 0 var(--space-5)"></p>`}
? `<p style="color:var(--c-text-secondary);margin:0 0 var(--space-2)">${_esc(dog.rasse)}</p>`
: `<p style="margin:0 0 var(--space-2)"></p>`}
<!-- Rassen-Community-Chip (wird async geladen) -->
<div id="dp-same-breed-chip" style="margin-bottom:var(--space-4)"></div>
<!-- Info-Grid -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);
@ -245,6 +248,9 @@ window.Page_dog_profile = (() => {
// Pflegetipps laden
_loadPflegeTipps(dog);
// Rassen-Community-Chip laden (falls Rasse bekannt)
if (dog.rasse) _loadSameBreedChip();
// Sitter-Zugang laden (nur für Besitzer)
if (dog.user_id === _appState.user?.id) {
_loadSittingAccess(dog.id);
@ -2386,6 +2392,32 @@ window.Page_dog_profile = (() => {
}
// ----------------------------------------------------------
// RASSEN-COMMUNITY-CHIP
// ----------------------------------------------------------
async function _loadSameBreedChip() {
const el = document.getElementById('dp-same-breed-chip');
if (!el) return;
try {
const data = await API.get('friends/same-breed');
if (!data || data.count === 0) return;
const hauptRasse = data.rassen[0]?.rasse || '';
const label = data.count === 1
? `1 anderer ${_esc(hauptRasse)}-Halter in der App`
: `${data.count} andere ${_esc(hauptRasse)}-Halter in der App`;
el.innerHTML = `
<button class="breed-community-chip" id="dp-breed-chip-btn">
🐕 ${label} &mdash; Forum ansehen
</button>
`;
document.getElementById('dp-breed-chip-btn')?.addEventListener('click', () => {
App.navigate('forum', false, { search: hauptRasse });
});
} catch {}
}
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------

View file

@ -62,12 +62,21 @@ window.Page_forum = (() => {
// ----------------------------------------------------------
// INIT
// ----------------------------------------------------------
async function init(container, appState) {
async function init(container, appState, params = {}) {
_container = container;
_appState = appState;
_render();
_loadHdmCard();
_loadThreads(true);
// Rassen-Suche vorausfüllen (Feature 3: Same-Breed-Chip)
if (params.search) {
const searchInput = document.getElementById('forum-search');
if (searchInput) {
searchInput.value = params.search;
searchInput.dispatchEvent(new Event('input', { bubbles: true }));
}
}
}
function refresh() {

View file

@ -9,9 +9,12 @@ window.Page_walks = (() => {
let _appState = null;
let _data = [];
let _view = 'liste'; // 'liste' | 'karte'
let _tab = 'treffen'; // 'treffen' | 'challenge' | 'stamm'
let _map = null;
let _markers = [];
let _userPos = null;
let _challengeData = null;
let _gassiZeiten = [];
// _esc ersetzt durch UI.escape()
@ -56,9 +59,17 @@ window.Page_walks = (() => {
_loadData();
}
function refresh() { _loadData(); }
function refresh() {
_loadData();
if (_tab === 'challenge') _loadChallenge();
if (_tab === 'stamm') _loadGassiZeiten();
}
function onDogChange() {}
function openNew() { _showCreateForm(); }
function openNew() {
if (_tab === 'challenge') { _showSubmitForm(); return; }
if (_tab === 'stamm') { _showGassiZeitForm(); return; }
_showCreateForm();
}
// ----------------------------------------------------------
// RENDER — Grundstruktur
@ -67,30 +78,63 @@ window.Page_walks = (() => {
_container.innerHTML = `
<div class="walks-layout">
<!-- Toolbar -->
<div class="by-toolbar">
<div class="walks-view-toggle" id="walks-view-toggle">
<button class="walks-view-btn active" data-view="liste">${UI.icon('list')} Liste</button>
<button class="walks-view-btn" data-view="karte">${UI.icon('map-trifold')} Karte</button>
</div>
<button class="btn btn-primary btn-sm" id="walks-create-btn">${UI.icon('plus')} Treffen planen</button>
<!-- Tab-Bar -->
<div class="by-tabs" id="walks-tab-bar" style="padding:var(--space-3) var(--space-4) 0">
<button class="by-tab active" data-tab="treffen">${UI.icon('paw-print')} Treffen</button>
<button class="by-tab" data-tab="challenge">${UI.icon('camera')} Challenge</button>
<button class="by-tab" data-tab="stamm">${UI.icon('clock')} Stamm-Gassis</button>
</div>
<!-- Liste -->
<div id="walks-list-view" class="walks-content">
<div id="walks-list">
<!-- Tab: Treffen -->
<div id="walks-tab-treffen" class="walks-tab-panel">
<!-- Toolbar -->
<div class="by-toolbar">
<div class="walks-view-toggle" id="walks-view-toggle">
<button class="walks-view-btn active" data-view="liste">${UI.icon('list')} Liste</button>
<button class="walks-view-btn" data-view="karte">${UI.icon('map-trifold')} Karte</button>
</div>
<button class="btn btn-primary btn-sm" id="walks-create-btn">${UI.icon('plus')} Treffen planen</button>
</div>
<!-- Liste -->
<div id="walks-list-view" class="walks-content">
<div id="walks-list">
<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Lädt</p>
</div>
</div>
<!-- Karte -->
<div id="walks-map-view" class="walks-content" style="display:none">
<div id="walks-map" class="walks-map"></div>
</div>
</div>
<!-- Tab: Challenge -->
<div id="walks-tab-challenge" class="walks-tab-panel" style="display:none">
<div id="challenge-content">
<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Lädt</p>
</div>
</div>
<!-- Karte -->
<div id="walks-map-view" class="walks-content" style="display:none">
<div id="walks-map" class="walks-map"></div>
<!-- Tab: Stamm-Gassis -->
<div id="walks-tab-stamm" class="walks-tab-panel" style="display:none">
<div class="by-toolbar">
<span style="font-weight:600;color:var(--c-text)">${UI.icon('clock')} Stamm-Gassi-Zeiten</span>
<button class="btn btn-primary btn-sm" id="gassi-zeit-add-btn">${UI.icon('plus')} Meine Zeit eintragen</button>
</div>
<div id="gassi-zeiten-content">
<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Lädt</p>
</div>
</div>
</div>
`;
// Tab-Bar Events
document.getElementById('walks-tab-bar').addEventListener('click', e => {
const btn = e.target.closest('.by-tab');
if (!btn) return;
_switchTab(btn.dataset.tab);
});
document.getElementById('walks-view-toggle').addEventListener('click', e => {
const btn = e.target.closest('.walks-view-btn');
if (!btn) return;
@ -105,6 +149,23 @@ window.Page_walks = (() => {
}
_showCreateForm();
});
document.getElementById('gassi-zeit-add-btn').addEventListener('click', () => {
if (!_appState.user) { UI.toast.warning('Bitte zuerst anmelden.'); App.navigate('settings'); return; }
_showGassiZeitForm();
});
}
function _switchTab(tab) {
_tab = tab;
document.querySelectorAll('.by-tab').forEach(b =>
b.classList.toggle('active', b.dataset.tab === tab));
document.querySelectorAll('.walks-tab-panel').forEach(p => p.style.display = 'none');
const panel = document.getElementById(`walks-tab-${tab}`);
if (panel) panel.style.display = '';
if (tab === 'challenge' && !_challengeData) _loadChallenge();
if (tab === 'stamm' && !_gassiZeiten.length) _loadGassiZeiten();
}
function _switchView(view) {
@ -1038,6 +1099,375 @@ window.Page_walks = (() => {
});
}
// ==============================================================
// FEATURE 1: Foto-Challenge der Woche
// ==============================================================
async function _loadChallenge() {
const el = document.getElementById('challenge-content');
if (!el) return;
try {
_challengeData = await API.get('challenges/current');
_renderChallenge();
} catch (e) {
el.innerHTML = `<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Konnte Challenge nicht laden.</p>`;
}
}
function _renderChallenge() {
const el = document.getElementById('challenge-content');
if (!el || !_challengeData) return;
const { challenge, submissions, my_submission_id, days_left } = _challengeData;
const canSubmit = _appState.user && !my_submission_id;
const dayLabel = days_left === 1 ? '1 Tag' : `${days_left} Tage`;
el.innerHTML = `
<div class="challenge-banner">
<div class="challenge-banner-inner">
<div class="challenge-thema">${UI.escape(challenge.thema)}</div>
<div class="challenge-meta">
${UI.icon('calendar')} ${_fmtDate(challenge.start_date)} ${_fmtDate(challenge.end_date)}
&nbsp;·&nbsp; ${UI.icon('timer')} Noch ${dayLabel}
</div>
${canSubmit ? `<button class="btn btn-primary btn-sm" id="challenge-submit-btn" style="margin-top:var(--space-3)">${UI.icon('camera')} Foto einreichen</button>` : ''}
${my_submission_id ? `<span class="badge badge-success" style="margin-top:var(--space-2)">${UI.icon('check')} Du hast bereits teilgenommen</span>` : ''}
</div>
</div>
<div class="challenge-winners" id="challenge-winners-section">
<h4 style="padding:var(--space-3) var(--space-4);margin:0;color:var(--c-text-secondary);font-size:var(--text-xs);text-transform:uppercase;letter-spacing:.05em">Letzte Gewinner</h4>
<div id="challenge-winners-list"><p style="color:var(--c-text-secondary);padding:0 var(--space-4);font-size:var(--text-sm)">Lädt</p></div>
</div>
<div style="padding:var(--space-4) var(--space-4) var(--space-2)">
<h4 style="margin:0;font-size:var(--text-sm);font-weight:600;color:var(--c-text)">
${UI.icon('images')} Einreichungen dieser Woche (${submissions.length})
</h4>
</div>
<div class="challenge-gallery" id="challenge-gallery">
${submissions.length === 0
? `<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8);grid-column:1/-1">Noch keine Fotos — sei der Erste! 📸</p>`
: submissions.map(s => _challengeSubmissionCard(s)).join('')}
</div>
`;
// Submit-Button
const submitBtn = document.getElementById('challenge-submit-btn');
if (submitBtn) submitBtn.addEventListener('click', _showSubmitForm);
// Vote-Buttons
el.querySelectorAll('.challenge-vote-btn').forEach(btn => {
btn.addEventListener('click', async () => {
if (!_appState.user) { UI.toast.warning('Bitte anmelden.'); return; }
const subId = parseInt(btn.dataset.id);
try {
const res = await API.post(`challenges/submissions/${subId}/vote`, {});
btn.querySelector('.vote-count').textContent = res.votes;
btn.classList.toggle('voted', res.voted);
} catch (e) { UI.toast.error(e.message || 'Fehler beim Abstimmen.'); }
});
});
// Gewinner laden
_loadChallengeWinners();
}
function _challengeSubmissionCard(s) {
const voted = s.i_voted;
return `
<div class="challenge-sub-card">
<img src="${UI.escape(s.foto_url)}" alt="Challenge-Foto" loading="lazy"
onerror="this.src='/icons/icon-192.png'"
onclick="UI.lightbox?.show?.([{ url:'${UI.escape(s.foto_url)}' }], 0)">
<div class="challenge-sub-info">
<div class="challenge-sub-user">${UI.icon('user')} ${UI.escape(s.user_name || 'Anonym')}
${s.dog_name ? ` · 🐕 ${UI.escape(s.dog_name)}` : ''}</div>
${s.caption ? `<div class="challenge-sub-caption">${UI.escape(s.caption)}</div>` : ''}
<button class="challenge-vote-btn ${voted ? 'voted' : ''}" data-id="${s.id}">
${UI.icon(voted ? 'heart-fill' : 'heart')} <span class="vote-count">${s.votes}</span>
</button>
</div>
</div>
`;
}
async function _loadChallengeWinners() {
const el = document.getElementById('challenge-winners-list');
if (!el) return;
try {
const winners = await API.get('challenges/winners');
if (!winners.length) { el.innerHTML = '<p style="color:var(--c-text-secondary);padding:0 var(--space-4);font-size:var(--text-sm)">Noch keine vergangenen Challenges.</p>'; return; }
el.innerHTML = `<div class="challenge-winners-row">` +
winners.map(w => {
if (!w.winner) return `<div class="challenge-winner-chip"><span>${UI.escape(w.challenge.thema)}</span><small>Kein Gewinner</small></div>`;
return `<div class="challenge-winner-chip">
<img src="${UI.escape(w.winner.foto_url)}" alt="Gewinner" onerror="this.src='/icons/icon-192.png'">
<div>
<div style="font-weight:600;font-size:var(--text-xs)">${UI.escape(w.challenge.thema)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">${UI.escape(w.winner.user_name)} · ${w.winner.votes} </div>
</div>
</div>`;
}).join('') +
`</div>`;
} catch {}
}
async function _showSubmitForm() {
if (!_challengeData) return;
const dogs = _appState.dogs || [];
const dogOptions = dogs.map(d => `<option value="${d.id}">${UI.escape(d.name)}</option>`).join('');
UI.modal.open({
title: `📸 ${UI.escape(_challengeData.challenge.thema)}`,
body: `
<form id="challenge-submit-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div class="form-group">
<label>Foto *</label>
<input type="file" id="challenge-foto-input" accept="image/*" required style="width:100%">
</div>
${dogs.length ? `<div class="form-group">
<label>Hund</label>
<select id="challenge-dog-select" style="width:100%">
<option value="">Kein Hund</option>
${dogOptions}
</select>
</div>` : ''}
<div class="form-group">
<label>Bildunterschrift</label>
<input type="text" id="challenge-caption" placeholder="z.B. Mein Bello beim besten Schnüffeln…" maxlength="200" style="width:100%">
</div>
</form>
`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" id="challenge-submit-ok">Einreichen</button>
`,
});
document.getElementById('challenge-submit-ok').addEventListener('click', async () => {
const fotoInput = document.getElementById('challenge-foto-input');
if (!fotoInput.files.length) { UI.toast.warning('Bitte ein Foto auswählen.'); return; }
const caption = document.getElementById('challenge-caption')?.value?.trim() || '';
const dogSelect = document.getElementById('challenge-dog-select');
const dogId = dogSelect ? (parseInt(dogSelect.value) || '') : '';
const fd = new FormData();
fd.append('foto', fotoInput.files[0]);
if (caption) fd.append('caption', caption);
if (dogId) fd.append('dog_id', dogId);
try {
await API.upload(`challenges/${_challengeData.challenge.id}/submit`, fd);
UI.toast.success('Foto eingereicht! Viel Erfolg 🎉');
UI.modal.close();
_challengeData = null;
_loadChallenge();
} catch (e) { UI.toast.error(e.message || 'Fehler beim Einreichen.'); }
});
}
// ==============================================================
// FEATURE 2: Gassi-Zeiten-Pool (Stamm-Gassis)
// ==============================================================
const _WOCHENTAGE = [
{ key: 'mo', label: 'Mo' }, { key: 'di', label: 'Di' }, { key: 'mi', label: 'Mi' },
{ key: 'do', label: 'Do' }, { key: 'fr', label: 'Fr' }, { key: 'sa', label: 'Sa' },
{ key: 'so', label: 'So' },
];
async function _loadGassiZeiten() {
const el = document.getElementById('gassi-zeiten-content');
if (!el) return;
try {
const params = _userPos ? `?lat=${_userPos.lat}&lon=${_userPos.lon}&radius=10000` : '';
_gassiZeiten = await API.get(`gassi-zeiten${params}`);
_renderGassiZeiten();
} catch (e) {
el.innerHTML = `<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Konnte Gassi-Zeiten nicht laden.</p>`;
}
}
function _renderGassiZeiten() {
const el = document.getElementById('gassi-zeiten-content');
if (!el) return;
if (!_gassiZeiten.length) {
el.innerHTML = `
<div style="text-align:center;padding:var(--space-8);color:var(--c-text-secondary)">
${UI.icon('clock')}
<p>Noch keine Stamm-Gassi-Zeiten in deiner Nähe.</p>
<p style="font-size:var(--text-sm)">Trag deine regelmäßigen Zeiten ein andere finden dich dann!</p>
</div>`;
return;
}
const myZeiten = _gassiZeiten.filter(z => z.is_mine);
const andereZeiten = _gassiZeiten.filter(z => !z.is_mine);
let html = '';
if (myZeiten.length) {
html += `<div style="padding:var(--space-3) var(--space-4) var(--space-1);font-weight:600;font-size:var(--text-xs);color:var(--c-text-secondary);text-transform:uppercase;letter-spacing:.05em">Meine Zeiten</div>`;
html += myZeiten.map(z => _gassiZeitCard(z)).join('');
}
if (andereZeiten.length) {
html += `<div style="padding:var(--space-3) var(--space-4) var(--space-1);font-weight:600;font-size:var(--text-xs);color:var(--c-text-secondary);text-transform:uppercase;letter-spacing:.05em">In deiner Nähe</div>`;
html += andereZeiten.map(z => _gassiZeitCard(z)).join('');
}
el.innerHTML = html;
// Events
el.querySelectorAll('.gz-delete-btn').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm('Gassi-Zeit löschen?')) return;
try {
await API.del(`gassi-zeiten/${btn.dataset.id}`);
_gassiZeiten = _gassiZeiten.filter(z => z.id !== parseInt(btn.dataset.id));
_renderGassiZeiten();
UI.toast.success('Gelöscht.');
} catch (e) { UI.toast.error(e.message || 'Fehler.'); }
});
});
el.querySelectorAll('.gz-toggle-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const gz = _gassiZeiten.find(z => z.id === parseInt(btn.dataset.id));
if (!gz) return;
try {
const updated = await API.patch(`gassi-zeiten/${gz.id}`, { aktiv: gz.aktiv ? 0 : 1 });
const idx = _gassiZeiten.findIndex(z => z.id === gz.id);
if (idx !== -1) _gassiZeiten[idx] = { ..._gassiZeiten[idx], aktiv: updated.aktiv };
_renderGassiZeiten();
} catch (e) { UI.toast.error(e.message || 'Fehler.'); }
});
});
el.querySelectorAll('.gz-chat-btn').forEach(btn => {
btn.addEventListener('click', () => {
const userId = parseInt(btn.dataset.userId);
App.navigate('chat', { user_id: userId });
});
});
}
function _gassiZeitCard(z) {
const wochentageLabel = (z.wochentage || []).join(', ').toUpperCase();
const distLabel = z.distance_m != null
? `<span style="color:var(--c-text-secondary);font-size:var(--text-xs)">${z.distance_m < 1000 ? z.distance_m + ' m' : (z.distance_m/1000).toFixed(1) + ' km'}</span>`
: '';
const pausedStyle = z.aktiv ? '' : 'opacity:.5';
return `
<div class="gassi-zeit-card" style="${pausedStyle}">
<div class="gz-avatar">
${z.dog_foto_url
? `<img src="${UI.escape(z.dog_foto_url)}" alt="${UI.escape(z.dog_name || '')}">`
: `<div class="gz-avatar-placeholder">${UI.icon('paw-print')}</div>`}
</div>
<div class="gz-body">
<div class="gz-name">${UI.escape(z.dog_name || z.user_name || '?')}
${z.dog_rasse ? `<span class="badge" style="font-size:var(--text-xs)">${UI.escape(z.dog_rasse)}</span>` : ''}
${!z.aktiv ? `<span class="badge badge-warning">Pausiert</span>` : ''}
</div>
<div class="gz-meta">
${UI.icon('clock')} ${UI.escape(z.uhrzeit)}
&nbsp;·&nbsp; ${wochentageLabel}
${z.ort_name ? `&nbsp;·&nbsp; ${UI.icon('map-pin')} ${UI.escape(z.ort_name)}` : ''}
${distLabel}
</div>
${z.notiz ? `<div class="gz-notiz">${UI.escape(z.notiz)}</div>` : ''}
</div>
<div class="gz-actions">
${z.is_mine ? `
<button class="btn btn-outline btn-xs gz-toggle-btn" data-id="${z.id}" title="${z.aktiv ? 'Pausieren' : 'Aktivieren'}">
${UI.icon(z.aktiv ? 'pause' : 'play')}
</button>
<button class="btn btn-outline btn-xs gz-delete-btn" data-id="${z.id}" title="Löschen">
${UI.icon('trash')}
</button>
` : `
<button class="btn btn-primary btn-xs gz-chat-btn" data-user-id="${z.user_id}" title="Chat öffnen">
${UI.icon('chat-circle')} Mitmachen
</button>
`}
</div>
</div>
`;
}
async function _showGassiZeitForm() {
const dogs = _appState.dogs || [];
const dogOptions = dogs.map(d => `<option value="${d.id}">${UI.escape(d.name)}</option>`).join('');
const wdBtns = _WOCHENTAGE.map(w =>
`<label class="wd-btn"><input type="checkbox" value="${w.key}"> ${w.label}</label>`
).join('');
UI.modal.open({
title: `${UI.icon('clock')} Stamm-Gassi-Zeit eintragen`,
body: `
<form id="gassi-zeit-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
${dogs.length ? `<div class="form-group">
<label>Hund</label>
<select id="gz-dog-select" style="width:100%">
<option value="">Kein Hund</option>
${dogOptions}
</select>
</div>` : ''}
<div class="form-group">
<label>Uhrzeit *</label>
<input type="time" id="gz-uhrzeit" required style="width:100%">
</div>
<div class="form-group">
<label>Wochentage *</label>
<div class="wd-selector">${wdBtns}</div>
</div>
<div class="form-group">
<label>Ort (optional)</label>
<input type="text" id="gz-ort-name" placeholder="z.B. Stadtpark Ebersberg" style="width:100%">
</div>
<div class="form-group">
<label>Notiz (optional)</label>
<input type="text" id="gz-notiz" placeholder="z.B. Wir sind eine ruhige Gruppe…" maxlength="200" style="width:100%">
</div>
</form>
`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" id="gz-save-btn">Speichern</button>
`,
});
document.getElementById('gz-save-btn').addEventListener('click', async () => {
const uhrzeit = document.getElementById('gz-uhrzeit')?.value;
if (!uhrzeit) { UI.toast.warning('Bitte Uhrzeit angeben.'); return; }
const wochentage = Array.from(document.querySelectorAll('.wd-btn input:checked')).map(cb => cb.value);
if (!wochentage.length) { UI.toast.warning('Bitte mindestens einen Wochentag wählen.'); return; }
const dogId = parseInt(document.getElementById('gz-dog-select')?.value) || null;
const ortName = document.getElementById('gz-ort-name')?.value?.trim() || null;
const notiz = document.getElementById('gz-notiz')?.value?.trim() || null;
const payload = { wochentage, uhrzeit, ort_name: ortName, notiz };
if (dogId) payload.dog_id = dogId;
if (_userPos) { payload.lat = _userPos.lat; payload.lon = _userPos.lon; }
try {
const created = await API.post('gassi-zeiten', payload);
_gassiZeiten.unshift({ ...created });
_renderGassiZeiten();
UI.toast.success('Gassi-Zeit eingetragen! 🐾');
UI.modal.close();
} catch (e) { UI.toast.error(e.message || 'Fehler beim Speichern.'); }
});
}
return { init, refresh, onDogChange, openNew, openDetail: _openDetail };
})();

View file

@ -10,9 +10,9 @@ const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
// index.html wird NICHT pre-gecacht (immer Network-First)
const STATIC_ASSETS = [
'/css/design-system.css?v=545',
'/css/layout.css?v=545',
'/css/components.css?v=545',
'/css/design-system.css?v=700',
'/css/layout.css?v=700',
'/css/components.css?v=700',
'/icons/phosphor.svg',
'/js/api.js',
'/js/ui.js',