Feature: Zuchtkartei Profilfotos-Button — Upload, Logo setzen, Sichtbarkeit (SW by-v902)

This commit is contained in:
rene 2026-05-13 19:09:29 +02:00
parent c417891546
commit f7a2a3861e
5 changed files with 151 additions and 8 deletions

View file

@ -591,10 +591,10 @@
<div id="modal-container"></div>
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
<script src="/js/api.js?v=901"></script>
<script src="/js/ui.js?v=901"></script>
<script src="/js/app.js?v=901"></script>
<script src="/js/worlds.js?v=901"></script>
<script src="/js/api.js?v=902"></script>
<script src="/js/ui.js?v=902"></script>
<script src="/js/app.js?v=902"></script>
<script src="/js/worlds.js?v=902"></script>
<!-- Feature-Seiten werden lazy geladen -->

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '901'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '902'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.5.1'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app';
// Cache-Bust-Parameter nach Update-Reload sofort entfernen

View file

@ -11,6 +11,7 @@ window.Page_zuchthunde = (() => {
let _hunde = []; // geladene Hunde
let _query = ''; // Suchtext
let _openSections = {}; // { <hundId>: 'health'|'genetic'|'titles'|null }
let _breederId = null; // ID des Züchter-Profils
// ----------------------------------------------------------
// Hilfsfunktionen
@ -110,6 +111,10 @@ window.Page_zuchthunde = (() => {
<button class="btn btn-secondary btn-sm" id="zh-trial-btn" style="flex-shrink:0;white-space:nowrap">
${UI.icon('heart-fill')} Probeverpaarung
</button>
<button class="btn btn-ghost btn-sm" id="zh-photos-btn" style="flex-shrink:0;white-space:nowrap"
title="Fotos & Logo für das Züchter-Profil verwalten">
${UI.icon('images')} Profilfotos
</button>
<a href="/api/breeder/export" download class="btn btn-ghost btn-sm" id="zh-export-btn"
style="flex-shrink:0" title="Alle Daten herunterladen (HTML + ODS)">
${UI.icon('download-simple')} Export
@ -136,6 +141,10 @@ window.Page_zuchthunde = (() => {
document.getElementById('zh-trial-btn')?.addEventListener('click', () => _showTrialMatingModal());
document.getElementById('zh-jahresbericht-btn')?.addEventListener('click', () => _showJahresbericht());
document.getElementById('zh-jahresbericht-archiv-btn')?.addEventListener('click', () => _showJahresberichtArchiv());
document.getElementById('zh-photos-btn')?.addEventListener('click', () => {
if (!_breederId) { UI.toast.warning('Züchter-Profil noch nicht geladen.'); return; }
_showBreederPhotosModal(_breederId);
});
document.getElementById('zh-search')?.addEventListener('input', e => {
_query = e.target.value.toLowerCase().trim();
@ -148,7 +157,10 @@ window.Page_zuchthunde = (() => {
// ----------------------------------------------------------
async function _load() {
try {
_hunde = await API.zuchthunde.list();
[_hunde] = await Promise.all([
API.zuchthunde.list(),
API.breeder.status().then(s => { _breederId = s?.id || null; }).catch(() => {}),
]);
_renderList();
} catch (err) {
const el = document.getElementById('zh-list');
@ -1649,6 +1661,137 @@ window.Page_zuchthunde = (() => {
document.head.appendChild(s);
})();
// ----------------------------------------------------------
// Profilfotos & Logo verwalten
// ----------------------------------------------------------
async function _showBreederPhotosModal(breederId) {
const galleryId = 'bp-gallery';
const visLabels = {
public: { text: 'Öffentlich', color: '#16a34a' },
inquiry: { text: 'Anfrage', color: '#f59e0b' },
private: { text: 'Privat', color: '#6b7280' },
};
const visOrder = ['public', 'inquiry', 'private'];
UI.modal.open({
title: `${UI.icon('images')} Züchter-Profilfotos & Logo`,
body: `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-3)">
Diese Fotos erscheinen im öffentlichen Züchterprofil. Das primäre Foto wird als <strong>Logo</strong> im Hero angezeigt.
</p>
<div id="${galleryId}" style="margin-bottom:var(--space-4)">
<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Lädt</p>
</div>
<hr style="margin:var(--space-3) 0;border:none;border-top:1px solid var(--c-border)">
<form id="bp-upload-form" style="display:flex;flex-direction:column;gap:var(--space-2)">
<label style="font-size:var(--text-sm);font-weight:600">${UI.icon('upload-simple')} Foto hochladen</label>
<input class="form-control" type="file" name="file" accept="image/*" required>
<input class="form-control" type="text" name="caption" placeholder="Bildunterschrift (optional)">
</form>`,
footer: `<button type="submit" form="bp-upload-form" class="btn btn-primary" id="bp-upload-btn">
${UI.icon('upload-simple')} Hochladen
</button>`,
});
async function _loadGallery() {
const el = document.getElementById(galleryId);
if (!el) return;
try {
const photos = await API.breederPhotos.list('breeder', breederId);
if (!photos.length) {
el.innerHTML = `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Fotos — lade das erste hoch.</p>`;
return;
}
el.innerHTML = `
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));gap:var(--space-2)">
${photos.map(ph => {
const thumb = ph.thumbnail_url || ph.url || '';
const vis = visLabels[ph.visibility] || visLabels.private;
const isPrimary = ph.is_primary;
return `
<div style="position:relative;border-radius:var(--radius-md);overflow:hidden;
border:${isPrimary ? '2px solid var(--c-primary)' : '1px solid var(--c-border)'};aspect-ratio:1"
data-photo-id="${ph.id}">
<a href="${_esc(ph.url || '')}" target="_blank" rel="noopener noreferrer">
<img src="${_esc(thumb)}" alt="${_esc(ph.caption || '')}"
loading="lazy" style="width:100%;height:100%;object-fit:cover;display:block"
onerror="this.parentElement.parentElement.style.opacity='.4'">
</a>
${isPrimary ? `<span style="position:absolute;top:3px;left:3px;background:var(--c-primary);color:white;
font-size:9px;font-weight:700;border-radius:999px;padding:1px 5px">Logo</span>` : ''}
<!-- Sichtbarkeit -->
<button class="bp-vis-btn" data-photo-id="${ph.id}" data-vis="${_esc(ph.visibility)}"
style="position:absolute;bottom:0;left:0;right:0;background:${vis.color};color:#fff;
border:none;cursor:pointer;font-size:9px;padding:2px 4px;font-weight:700">
${vis.text}
</button>
<!-- Als Logo setzen -->
${!isPrimary ? `<button class="bp-primary-btn" data-photo-id="${ph.id}" title="Als Logo setzen"
style="position:absolute;top:2px;right:24px;background:rgba(0,0,0,.5);color:#fff;
border:none;border-radius:50%;cursor:pointer;width:20px;height:20px;
display:flex;align-items:center;justify-content:center;font-size:10px">
${UI.icon('star')}
</button>` : ''}
<!-- Löschen -->
<button class="bp-del-btn" data-photo-id="${ph.id}" title="Löschen"
style="position:absolute;top:2px;right:2px;background:rgba(0,0,0,.5);color:#fff;
border:none;border-radius:50%;cursor:pointer;width:20px;height:20px;
display:flex;align-items:center;justify-content:center;font-size:10px">
${UI.icon('x')}
</button>
</div>`;
}).join('')}
</div>`;
el.querySelectorAll('.bp-vis-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const next = visOrder[(visOrder.indexOf(btn.dataset.vis) + 1) % visOrder.length];
try { await API.breederPhotos.updateVisibility(parseInt(btn.dataset.photoId), next); _loadGallery(); }
catch (err) { UI.toast.error(err.message); }
});
});
el.querySelectorAll('.bp-primary-btn').forEach(btn => {
btn.addEventListener('click', async () => {
try { await API.breederPhotos.setPrimary(parseInt(btn.dataset.photoId)); _loadGallery(); UI.toast.success('Als Logo gesetzt.'); }
catch (err) { UI.toast.error(err.message); }
});
});
el.querySelectorAll('.bp-del-btn').forEach(btn => {
btn.addEventListener('click', async () => {
if (!window.confirm('Foto löschen?')) return;
try { await API.breederPhotos.remove(parseInt(btn.dataset.photoId)); _loadGallery(); }
catch (err) { UI.toast.error(err.message); }
});
});
} catch (err) {
const el = document.getElementById(galleryId);
if (el) el.innerHTML = `<p style="color:var(--c-danger)">${_esc(err.message || 'Fehler')}</p>`;
}
}
_loadGallery();
document.getElementById('bp-upload-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = document.getElementById('bp-upload-btn');
const fileInput = e.target.querySelector('[name="file"]');
const caption = e.target.querySelector('[name="caption"]')?.value?.trim() || '';
if (!fileInput?.files?.length) { UI.toast.error('Bitte Datei auswählen.'); return; }
const fd = new FormData();
fd.append('entity_type', 'breeder');
fd.append('entity_id', String(breederId));
fd.append('visibility', 'public');
fd.append('caption', caption);
fd.append('file', fileInput.files[0]);
await UI.asyncButton(btn, async () => {
await API.breederPhotos.upload(fd);
UI.toast.success('Foto hochgeladen.');
e.target.reset();
await _loadGallery();
});
});
}
return { init, refresh, onDogChange };
})();

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache
============================================================ */
const CACHE_VERSION = 'by-v901';
const CACHE_VERSION = 'by-v902';
const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache