Sprint 11: Freunde & Chat + Phosphor-Icon-Vollmigration

- Freundschaften (pending/accepted), Nutzersuche, Anfragen per Push
- Direktnachrichten mit Polling, iMessage-Stil, Deep-Links aus Push
- Alle Seiten (map, places, diary, health, dog-profile, sitting, knigge,
  forum, wiki, walks) vollständig auf Phosphor-Icons migriert
- Wikidata-Rassen-Scraper (~833 neue Rassen, lokal gespiegelte Fotos)
- TheDogAPI lokal gespiegelt (169 Rassen + Fotos)
- Quiz-Result-Cards horizontal (korrekte Bildproportionen)
- SW by-v89
This commit is contained in:
rene 2026-04-15 21:33:53 +02:00
parent 96bd57f0ad
commit 097295c628
44 changed files with 9980 additions and 300 deletions

View file

@ -0,0 +1,409 @@
/* ============================================================
BAN YARO Hunde-Filme
Seiten-Modul: Film-Datenbank, Promi-Hunde, Hund des Monats.
============================================================ */
window.Page_movies = (() => {
// ----------------------------------------------------------
// MODUL-STATE
// ----------------------------------------------------------
let _container = null;
let _appState = null;
let _filme = [];
let _activeTab = 'filme';
let _filter = 'alle';
// ----------------------------------------------------------
// INIT
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
await _render();
}
// ----------------------------------------------------------
// REFRESH
// ----------------------------------------------------------
async function refresh() {
_filme = [];
await _render();
}
// ----------------------------------------------------------
// RENDER — Haupt-Layout mit Tabs
// ----------------------------------------------------------
async function _render() {
_container.innerHTML = `
<div class="movies-tabs">
<button class="movies-tab${_activeTab === 'filme' ? ' movies-tab--active' : ''}" data-tab="filme">${UI.icon('film-slate')} Filme</button>
<button class="movies-tab${_activeTab === 'promis' ? ' movies-tab--active' : ''}" data-tab="promis">${UI.icon('star')} Berühmtheiten</button>
<button class="movies-tab${_activeTab === 'hdm' ? ' movies-tab--active' : ''}" data-tab="hdm">${UI.icon('paw-print')} Hund des Monats</button>
</div>
<div id="movies-tab-content"></div>
`;
_container.querySelectorAll('.movies-tab').forEach(btn => {
btn.addEventListener('click', () => {
_activeTab = btn.dataset.tab;
_container.querySelectorAll('.movies-tab').forEach(b => b.classList.remove('movies-tab--active'));
btn.classList.add('movies-tab--active');
_renderTab();
});
});
await _renderTab();
}
async function _renderTab() {
const content = _container.querySelector('#movies-tab-content');
if (!content) return;
content.innerHTML = UI.skeleton(3);
if (_activeTab === 'filme') await _renderFilme(content);
if (_activeTab === 'promis') _renderPromis(content);
if (_activeTab === 'hdm') await _renderHundDesMonats(content);
}
// ----------------------------------------------------------
// TAB 1: FILME
// ----------------------------------------------------------
async function _renderFilme(content) {
try {
_filme = await API.get('/movies/filme');
} catch {
content.innerHTML = UI.emptyState({ icon: '🎬', title: 'Filme konnten nicht geladen werden', text: 'Bitte versuche es erneut.' });
return;
}
content.innerHTML = `
<div class="movies-filter-row">
<button class="movies-filter-btn${_filter === 'alle' ? ' movies-filter-btn--active' : ''}" data-filter="alle">Alle</button>
<button class="movies-filter-btn${_filter === 'stirbt' ? ' movies-filter-btn--active' : ''}" data-filter="stirbt">😭 Hund stirbt</button>
<button class="movies-filter-btn${_filter === 'ueberlebt' ? ' movies-filter-btn--active' : ''}" data-filter="ueberlebt">🐾 Hund überlebt</button>
<button class="movies-filter-btn${_filter === 'top' ? ' movies-filter-btn--active' : ''}" data-filter="top">+</button>
</div>
<div class="movie-grid" id="movie-grid"></div>
`;
content.querySelectorAll('.movies-filter-btn').forEach(btn => {
btn.addEventListener('click', () => {
_filter = btn.dataset.filter;
content.querySelectorAll('.movies-filter-btn').forEach(b => b.classList.remove('movies-filter-btn--active'));
btn.classList.add('movies-filter-btn--active');
_renderMovieGrid(content.querySelector('#movie-grid'));
});
});
_renderMovieGrid(content.querySelector('#movie-grid'));
}
function _renderMovieGrid(grid) {
if (!grid) return;
let list = [..._filme];
if (_filter === 'stirbt') list = list.filter(f => f.stirbt_der_hund);
if (_filter === 'ueberlebt') list = list.filter(f => !f.stirbt_der_hund);
if (_filter === 'top') list = list.filter(f => f.bewertung_avg >= 4.0);
if (list.length === 0) {
grid.innerHTML = `<div style="grid-column:1/-1;padding:var(--space-8);text-align:center;color:var(--c-text-secondary)">Keine Filme für diesen Filter.</div>`;
return;
}
grid.innerHTML = list.map(f => _movieCard(f)).join('');
grid.querySelectorAll('.movie-card').forEach(card => {
card.addEventListener('click', (e) => {
if (e.target.closest('.movie-star-rating')) return;
const id = card.dataset.filmId;
const film = _filme.find(f => f.id === id);
if (film) _openMovieModal(film);
});
});
_bindStarRatings(grid);
}
function _movieCard(film) {
const stirbt = film.stirbt_der_hund;
const tag = stirbt
? `<div class="movie-tag-stirbt">⚠️ ACHTUNG: Der Hund stirbt</div>`
: `<div class="movie-tag-ueberlebt">✅ Der Hund überlebt</div>`;
const stars = _starsHtml(film.bewertung_avg, film.id, film.user_rating, false);
return `
<div class="movie-card" data-film-id="${_esc(film.id)}">
<div class="movie-card-emoji">${film.bild_emoji}</div>
<div class="movie-card-body">
<div class="movie-card-title">${_esc(film.titel)} <span class="movie-card-year">(${film.jahr})</span></div>
<div class="movie-card-genre">${_esc(film.genre)}</div>
<div class="movie-card-rasse">🐾 ${_esc(film.hund_rasse)}</div>
${tag}
<div class="movie-card-stars">${stars}</div>
</div>
</div>
`;
}
function _openMovieModal(film) {
const stirbt = film.stirbt_der_hund;
const bannerClass = stirbt ? 'movie-tag-stirbt' : 'movie-tag-ueberlebt';
const bannerText = stirbt ? '⚠️ ACHTUNG: Der Hund stirbt!' : '✅ Der Hund überlebt';
const stars = _starsHtml(film.bewertung_avg, film.id, film.user_rating, true);
const loginHint = !_appState.user
? `<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-top:var(--space-2)">Anmelden um zu bewerten</p>`
: '';
const body = `
<div class="movie-modal-emoji">${film.bild_emoji}</div>
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-3)">
<span class="badge badge-primary">${_esc(film.genre)}</span>
<span class="badge">🐾 ${_esc(film.hund_rasse)}</span>
<span class="badge">${film.jahr}</span>
</div>
<div class="${bannerClass}" style="margin-bottom:var(--space-4);font-size:var(--text-base)">${bannerText}</div>
<p style="line-height:1.6;color:var(--c-text);margin-bottom:var(--space-5)">${_esc(film.beschreibung)}</p>
<div style="margin-bottom:var(--space-2)">
<strong>Community-Bewertung:</strong>
</div>
<div id="modal-stars-${_esc(film.id)}">${stars}</div>
<div id="modal-avg-${_esc(film.id)}" style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-top:var(--space-1)">
Ø ${film.bewertung_avg} von ${film.bewertung_cnt || 0} Bewertungen
</div>
${loginHint}
`;
UI.modal.open({ title: film.titel, body });
const starsEl = document.getElementById(`modal-stars-${film.id}`);
if (starsEl && _appState.user) {
_bindStarRatingsEl(starsEl, film.id, true);
}
}
function _starsHtml(avg, filmId, userRating, interactive) {
const filled = Math.round(avg);
const stars = [1,2,3,4,5].map(i => {
const active = i <= (userRating || filled) ? ' movie-star--active' : '';
return `<span class="movie-star${active}" data-film-id="${_esc(filmId)}" data-val="${i}">★</span>`;
}).join('');
return `<div class="movie-star-rating" data-film-id="${_esc(filmId)}">${stars} <span class="movie-star-avg">${avg}</span></div>`;
}
function _bindStarRatings(container) {
container.querySelectorAll('.movie-star-rating').forEach(el => {
_bindStarRatingsEl(el, el.dataset.filmId, false);
});
}
function _bindStarRatingsEl(el, filmId, inModal) {
if (!_appState.user) return;
const stars = el.querySelectorAll('.movie-star');
stars.forEach(star => {
star.addEventListener('mouseenter', () => {
const val = parseInt(star.dataset.val);
stars.forEach((s, i) => s.classList.toggle('movie-star--active', i < val));
});
star.addEventListener('mouseleave', () => {
const film = _filme.find(f => f.id === filmId);
const cur = film?.user_rating || 0;
stars.forEach((s, i) => s.classList.toggle('movie-star--active', i < cur));
});
star.addEventListener('click', async () => {
const val = parseInt(star.dataset.val);
try {
const res = await API.post(`/movies/filme/${filmId}/vote`, { bewertung: val });
// Update in _filme array
const idx = _filme.findIndex(f => f.id === filmId);
if (idx !== -1) {
_filme[idx].user_rating = val;
_filme[idx].bewertung_avg = res.bewertung_avg;
_filme[idx].bewertung_cnt = res.bewertung_cnt;
}
// Update star display
stars.forEach((s, i) => s.classList.toggle('movie-star--active', i < val));
const avgEl = el.querySelector('.movie-star-avg') ||
(inModal ? document.getElementById(`modal-avg-${filmId}`) : null);
if (el.querySelector('.movie-star-avg')) {
el.querySelector('.movie-star-avg').textContent = res.bewertung_avg;
}
if (inModal) {
const avgInfo = document.getElementById(`modal-avg-${filmId}`);
if (avgInfo) avgInfo.textContent = `Ø ${res.bewertung_avg} von ${res.bewertung_cnt} Bewertungen`;
}
UI.toast.success('Bewertung gespeichert!');
} catch {
UI.toast.error('Bewertung konnte nicht gespeichert werden.');
}
});
});
}
// ----------------------------------------------------------
// TAB 2: BERÜHMTHEITEN (hardcoded, kein Backend)
// ----------------------------------------------------------
const PROMIS = [
{ name: "Hachikō", rasse: "Akita Inu", bekannt_fuer: "9 Jahre lang täglich auf seinen verstorbenen Herrchen am Bahnhof Shibuya gewartet. Statue in Tokio.", emoji: "🗿" },
{ name: "Rin Tin Tin", rasse: "Deutscher Schäferhund", bekannt_fuer: "Filmhund der 1920er-Jahre. Rettete Warner Bros. vor dem Bankrott. Erster Hundestar Hollywoods.", emoji: "🎬" },
{ name: "Laika", rasse: "Mischling", bekannt_fuer: "Erstes Lebewesen im Weltall (Sputnik 2, 1957). Wurde zur sowjetischen Weltraumpionierin.", emoji: "🚀" },
{ name: "Endal", rasse: "Labrador", bekannt_fuer: "Assistenzhund in England. Erster Hund der eine EC-Karte am Geldautomaten benutzte.", emoji: "💳" },
{ name: "Barry", rasse: "Bernhardiner", bekannt_fuer: "Legendärer Rettungshund der Alpen (18001812). Soll 40 Menschen das Leben gerettet haben.", emoji: "🏔️" },
{ name: "Greyfriars Bobby", rasse: "Skye Terrier", bekannt_fuer: "14 Jahre lang das Grab seines Herrchens in Edinburgh bewacht. Statue und Pub benannt nach ihm.", emoji: "⛪" },
];
function _renderPromis(content) {
content.innerHTML = `
<div style="padding:var(--space-2) 0">
${PROMIS.map(p => `
<div class="movie-promi-card">
<div class="movie-promi-emoji">${p.emoji}</div>
<div class="movie-promi-body">
<div class="movie-promi-name">${_esc(p.name)}</div>
<div class="movie-promi-rasse">${_esc(p.rasse)}</div>
<div class="movie-promi-text">${_esc(p.bekannt_fuer)}</div>
</div>
</div>
`).join('')}
</div>
`;
}
// ----------------------------------------------------------
// TAB 3: HUND DES MONATS
// ----------------------------------------------------------
async function _renderHundDesMonats(content) {
let data;
try {
data = await API.get('/movies/hund-des-monats');
} catch {
content.innerHTML = UI.emptyState({ icon: '🏆', title: 'Fehler beim Laden', text: 'Bitte versuche es erneut.' });
return;
}
const [year, month] = data.monat.split('-');
const monthName = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' })
.format(new Date(+year, +month - 1, 1));
let voteSection = '';
if (_appState.user && _appState.dogs?.length > 0) {
const voteCards = _appState.dogs.map(dog => {
const isVoted = data.user_vote === dog.id;
const av = dog.foto_url
? `<img src="${_esc(dog.foto_url)}" alt="${_esc(dog.name)}" class="hdm-vote-av-img">`
: `<span class="hdm-vote-av-placeholder">${_esc(dog.name.charAt(0).toUpperCase())}</span>`;
return `
<div class="hdm-vote-card${isVoted ? ' hdm-vote-card--voted' : ''}" data-dog-id="${dog.id}">
<div class="hdm-vote-av">${av}</div>
<div class="hdm-vote-name">${_esc(dog.name)}</div>
${dog.rasse ? `<div class="hdm-vote-rasse">${_esc(dog.rasse)}</div>` : ''}
<button class="btn${isVoted ? ' btn-primary' : ' btn-secondary'} hdm-vote-btn" data-dog-id="${dog.id}">
${isVoted ? '✅ Gewählt' : 'Abstimmen'}
</button>
</div>
`;
}).join('');
voteSection = `
<div class="hdm-section">
<h3 class="hdm-section-title">Für welchen deiner Hunde möchtest du abstimmen?</h3>
<div class="hdm-vote-grid" id="hdm-vote-grid">${voteCards}</div>
</div>
`;
} else if (!_appState.user) {
voteSection = `
<div class="hdm-section">
<p style="color:var(--c-text-secondary);font-size:var(--text-sm)">
<a href="#" id="hdm-login-link" style="color:var(--c-primary);font-weight:var(--weight-semibold)">Anmelden</a>
um für deinen Hund abzustimmen.
</p>
</div>
`;
}
const topList = data.top.length > 0
? data.top.slice(0, 5).map((dog, i) => {
const medal = ['🥇','🥈','🥉','4⃣','5⃣'][i] || `${i+1}.`;
const av = dog.foto_url
? `<img src="${_esc(dog.foto_url)}" alt="${_esc(dog.name)}" class="hdm-top-av-img">`
: `<span class="hdm-top-av-placeholder">${_esc(dog.name.charAt(0).toUpperCase())}</span>`;
const vorname = dog.besitzer_name ? _esc(dog.besitzer_name.split(' ')[0]) : '';
return `
<div class="hdm-top-entry">
<span class="hdm-top-medal">${medal}</span>
<div class="hdm-top-av">${av}</div>
<div class="hdm-top-info">
<div class="hdm-top-name">${_esc(dog.name)}</div>
${dog.rasse ? `<div class="hdm-top-rasse">${_esc(dog.rasse)}</div>` : ''}
${vorname ? `<div class="hdm-top-besitzer">von ${vorname}</div>` : ''}
</div>
<div class="hdm-top-stimmen">${dog.stimmen} </div>
</div>
`;
}).join('')
: `<p style="color:var(--c-text-secondary);padding:var(--space-4)">Noch keine Stimmen diesen Monat. Sei der Erste!</p>`;
content.innerHTML = `
<div class="hdm-header">
<div class="hdm-trophy">🏆</div>
<h2 class="hdm-title">Hund des Monats</h2>
<div class="hdm-monat">${_esc(monthName)}</div>
</div>
${voteSection}
<div class="hdm-section">
<h3 class="hdm-section-title">Top 5 diesen Monat</h3>
<div id="hdm-top-list">${topList}</div>
</div>
`;
// Login-Link
content.querySelector('#hdm-login-link')?.addEventListener('click', e => {
e.preventDefault();
App.navigate('settings');
});
// Vote-Buttons
content.querySelectorAll('.hdm-vote-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const dogId = parseInt(btn.dataset.dogId);
await UI.asyncButton(btn, async () => {
try {
await API.post('/movies/hund-des-monats/vote', { dog_id: dogId });
UI.toast.success('Stimme abgegeben!');
// Refresh the tab
await _renderHundDesMonats(content);
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Abstimmen.');
}
});
});
});
}
// ----------------------------------------------------------
// HELPER
// ----------------------------------------------------------
function _esc(str) {
if (!str && str !== 0) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------
return { init, refresh };
})();