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:
parent
d6206d378e
commit
aa4849d947
10 changed files with 1322 additions and 22 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} — Forum ansehen
|
||||
</button>
|
||||
`;
|
||||
document.getElementById('dp-breed-chip-btn')?.addEventListener('click', () => {
|
||||
App.navigate('forum', false, { search: hauptRasse });
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// PUBLIC
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
· ${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)}
|
||||
· ${wochentageLabel}
|
||||
${z.ort_name ? ` · ${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 };
|
||||
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue