Züchter-Editor: Wurfnamen sichtbar, 'undefined Medien' gefixt, Mitgliedschaften & Zertifikate
Rene: 'Züchter sollten mehr Einfluss haben — Wurfnamen (B-Wurf), Mitglied- schaften und Zertifikate fürs Profil.' - Wurfnamen: Infrastruktur existierte komplett (wurf_rang/wurf_name in DB, Backend, Wurfverwaltungs-Formular) — war nur im Editor und auf der öffentlichen Seite unsichtbar. Editor-Karten zeigen jetzt 'B-Wurf · Name' (Feldname-Bug geburtsdatum→geburt_datum), öffentliche Wurf-Karten bekommen den Rang/Namen als Badge, Hinweis im Editor verlinkt zur Vergabe. - 'undefined Medien': my-editor lieferte kein foto_count (+photos fürs Profil-Grid) — ergänzt. - NEU Mitgliedschaften & Zertifikate: entity_type 'certificate' im Foto- System (Ownership wie breeder), Editor-Sektion mit Upload (Bezeichnung als Caption) + Löschen, öffentliche Profilseite zeigt eigene Sektion mit Logos/Urkunden (klickbar, lazy). Public-Endpoint liefert result.zertifikate. Tests: my-editor inkl. Wurfname/foto_count, Zertifikat-Roundtrip bis zur öffentlichen Seite. Suite: 61 passed.
This commit is contained in:
parent
dfffd07a96
commit
5f01abc590
10 changed files with 186 additions and 28 deletions
|
|
@ -402,7 +402,8 @@ async def breeder_public_profile(zwingername: str):
|
|||
|
||||
# Sichtbare Würfe
|
||||
wuerfe = conn.execute("""
|
||||
SELECT id, vater_name, mutter_name, geburt_datum, erwartetes_datum,
|
||||
SELECT id, wurf_rang, wurf_name, vater_name, mutter_name,
|
||||
geburt_datum, erwartetes_datum,
|
||||
status, welpen_gesamt, welpen_verfuegbar, preis_spanne, beschreibung
|
||||
FROM litters
|
||||
WHERE breeder_id=? AND sichtbar=1 AND status != 'abgeschlossen'
|
||||
|
|
@ -410,6 +411,19 @@ async def breeder_public_profile(zwingername: str):
|
|||
""", (breeder_id,)).fetchall()
|
||||
result["wuerfe"] = [dict(w) for w in wuerfe]
|
||||
|
||||
# Mitgliedschaften & Zertifikate (öffentliche Logos/Badges mit Caption)
|
||||
certs = conn.execute("""
|
||||
SELECT id, file_path, thumbnail_path, caption FROM breeder_photos
|
||||
WHERE breeder_id=? AND entity_type='certificate' AND visibility='public'
|
||||
ORDER BY sort_order
|
||||
""", (breeder_id,)).fetchall()
|
||||
result["zertifikate"] = [{
|
||||
"id": c["id"],
|
||||
"url": f"/media/{c['file_path']}",
|
||||
"thumbnail_url": f"/media/{c['thumbnail_path']}" if c["thumbnail_path"] else f"/media/{c['file_path']}",
|
||||
"caption": c["caption"],
|
||||
} for c in certs]
|
||||
|
||||
# Gesundheits-Statistik (aggregiert über alle öffentlichen Hunde)
|
||||
hd_stats = conn.execute("""
|
||||
SELECT ergebnis, COUNT(*) as cnt FROM dog_health_tests
|
||||
|
|
@ -496,6 +510,7 @@ async def breeder_my_editor(user=Depends(require_breeder)):
|
|||
"""Daten für den Profil-Editor: Profil + eigene Würfe + Speicherverbrauch.
|
||||
(Frontend breeder-editor.js stammt aus 459cd42 — dieser Lese-Endpoint
|
||||
ging damals im Worktree-Merge verloren, wie /partner/my-profile.)"""
|
||||
from routes.breeder_photos import _photo_dict
|
||||
with db() as conn:
|
||||
profile = conn.execute(
|
||||
"SELECT * FROM breeder_profiles WHERE user_id=?", (user["id"],)
|
||||
|
|
@ -503,8 +518,20 @@ async def breeder_my_editor(user=Depends(require_breeder)):
|
|||
if not profile:
|
||||
raise HTTPException(404, "Noch kein Züchter-Profil angelegt.")
|
||||
profile = dict(profile)
|
||||
profile["photos"] = [_photo_dict(r) for r in conn.execute(
|
||||
"SELECT * FROM breeder_photos WHERE breeder_id=? AND entity_type='breeder' ORDER BY sort_order",
|
||||
(profile["id"],)
|
||||
).fetchall()]
|
||||
# Mitgliedschaften & Zertifikate (Logos/Badges fürs öffentliche Profil)
|
||||
profile["certificates"] = [_photo_dict(r) for r in conn.execute(
|
||||
"SELECT * FROM breeder_photos WHERE breeder_id=? AND entity_type='certificate' ORDER BY sort_order",
|
||||
(profile["id"],)
|
||||
).fetchall()]
|
||||
litters = [dict(r) for r in conn.execute(
|
||||
"SELECT * FROM litters WHERE breeder_id=? ORDER BY created_at DESC",
|
||||
"""SELECT l.*,
|
||||
(SELECT COUNT(*) FROM breeder_photos p
|
||||
WHERE p.entity_type='litter' AND p.entity_id=l.id) AS foto_count
|
||||
FROM litters l WHERE l.breeder_id=? ORDER BY l.created_at DESC""",
|
||||
(profile["id"],)
|
||||
).fetchall()]
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
||||
|
||||
_VALID_ENTITY_TYPES = {"breeder", "litter", "puppy", "parent"}
|
||||
_VALID_ENTITY_TYPES = {"breeder", "litter", "puppy", "parent", "certificate"}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -100,7 +100,7 @@ async def upload_photo(
|
|||
elif entity_type == "parent":
|
||||
# parent kann frei hochgeladen werden solange breeder stimmt
|
||||
pass
|
||||
elif entity_type == "breeder":
|
||||
elif entity_type in ("breeder", "certificate"):
|
||||
# entity_id muss das eigene Profil sein
|
||||
if entity_id != breeder_id and user["rolle"] != "admin":
|
||||
raise HTTPException(403, "Kein Zugriff auf dieses Züchter-Profil.")
|
||||
|
|
@ -200,7 +200,7 @@ async def get_photos(
|
|||
).fetchone()
|
||||
if bp:
|
||||
# Besitzer wenn entity dem Züchter gehört
|
||||
if entity_type == "breeder":
|
||||
if entity_type in ("breeder", "certificate"):
|
||||
is_owner = (bp["id"] == entity_id)
|
||||
elif entity_type == "litter":
|
||||
row = conn.execute(
|
||||
|
|
|
|||
|
|
@ -86,14 +86,14 @@
|
|||
<title>Ban Yaro</title>
|
||||
|
||||
<!-- Theme + theme-color Statusleiste vor CSS setzen -->
|
||||
<script src="/js/boot-early.js?v=1269"></script>
|
||||
<script src="/js/boot-early.js?v=1270"></script>
|
||||
|
||||
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
||||
<link rel="stylesheet" href="/css/design-system.css?v=1269">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=1269">
|
||||
<link rel="stylesheet" href="/css/components.css?v=1269">
|
||||
<link rel="stylesheet" href="/css/utilities.css?v=1269">
|
||||
<link rel="stylesheet" href="/css/lists.css?v=1269">
|
||||
<link rel="stylesheet" href="/css/design-system.css?v=1270">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=1270">
|
||||
<link rel="stylesheet" href="/css/components.css?v=1270">
|
||||
<link rel="stylesheet" href="/css/utilities.css?v=1270">
|
||||
<link rel="stylesheet" href="/css/lists.css?v=1270">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
|
@ -620,11 +620,11 @@
|
|||
<div id="modal-container"></div>
|
||||
|
||||
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
|
||||
<script src="/js/api.js?v=1269"></script>
|
||||
<script src="/js/ui.js?v=1269"></script>
|
||||
<script src="/js/app.js?v=1269"></script>
|
||||
<script src="/js/worlds.js?v=1269"></script>
|
||||
<script src="/js/offline-indicator.js?v=1269"></script>
|
||||
<script src="/js/api.js?v=1270"></script>
|
||||
<script src="/js/ui.js?v=1270"></script>
|
||||
<script src="/js/app.js?v=1270"></script>
|
||||
<script src="/js/worlds.js?v=1270"></script>
|
||||
<script src="/js/offline-indicator.js?v=1270"></script>
|
||||
|
||||
<!-- Feature-Seiten werden lazy geladen -->
|
||||
|
||||
|
|
@ -634,7 +634,7 @@
|
|||
|
||||
|
||||
<!-- Boot: Offline-Banner + SW-Registration (extrahiert für CSP) -->
|
||||
<script src="/js/boot.js?v=1269"></script>
|
||||
<script src="/js/boot.js?v=1270"></script>
|
||||
|
||||
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Router, State-Management, Navigation, Initialisierung.
|
||||
============================================================ */
|
||||
|
||||
const APP_VER = '1269'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
const APP_VER = '1270'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt
|
||||
window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator)
|
||||
window.APP_VERSION = APP_VERSION;
|
||||
|
|
|
|||
|
|
@ -138,6 +138,34 @@ window.Page_breeder_editor = (() => {
|
|||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Mitgliedschaften & Zertifikate -->
|
||||
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3)">
|
||||
<div style="font-size:var(--text-xs);font-weight:700;text-transform:uppercase;
|
||||
letter-spacing:.06em;color:var(--c-text-muted);margin-bottom:var(--space-2)">
|
||||
Mitgliedschaften & Zertifikate
|
||||
</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-2)">
|
||||
Vereins-Logos, VDH-Mitgliedschaft, Urkunden — werden auf deiner öffentlichen
|
||||
Profilseite in einer eigenen Sektion gezeigt.
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:var(--space-2);margin:var(--space-3) 0">
|
||||
${(p.certificates || []).map(c => `
|
||||
<div style="position:relative;border:1px solid var(--c-border);border-radius:var(--radius-md);overflow:hidden;background:var(--c-surface-2)">
|
||||
<img src="${UI.escape(c.thumbnail_url || c.url)}" style="width:100%;aspect-ratio:1;object-fit:contain;padding:6px;box-sizing:border-box">
|
||||
<div style="font-size:10px;text-align:center;padding:2px 4px 5px;color:var(--c-text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${UI.escape(c.caption || '—')}</div>
|
||||
<button class="be-cert-del" data-id="${c.id}"
|
||||
style="position:absolute;top:4px;right:4px;background:rgba(0,0,0,.6);
|
||||
border:none;border-radius:50%;width:24px;height:24px;cursor:pointer;
|
||||
color:#fff;font-size:14px;display:flex;align-items:center;justify-content:center">×</button>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
<label class="btn btn-secondary btn-sm" style="cursor:pointer;display:inline-flex;align-items:center;gap:6px">
|
||||
<svg class="ph-icon" style="width:16px;height:16px"><use href="/icons/phosphor.svg#plus"></use></svg>
|
||||
Logo / Urkunde hinzufügen
|
||||
<input type="file" id="be-cert-input" accept="image/*" class="hidden">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Würfe — Schnellupload -->
|
||||
${litters.length ? `
|
||||
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3)">
|
||||
|
|
@ -148,6 +176,10 @@ window.Page_breeder_editor = (() => {
|
|||
<div class="flex-col-gap-3">
|
||||
${litters.map(l => _renderLitterCard(l)).join('')}
|
||||
</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-2)">
|
||||
${UI.icon('info')} Wurf-Rang (A-, B-Wurf …) und Wurfnamen vergibst du in der
|
||||
<a href="#litters" class="text-primary">Wurfverwaltung</a>.
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
</div>
|
||||
|
|
@ -179,12 +211,14 @@ window.Page_breeder_editor = (() => {
|
|||
}
|
||||
|
||||
function _renderLitterCard(l) {
|
||||
const label = l.geburtsdatum
|
||||
? `Wurf vom ${new Date(l.geburtsdatum).toLocaleDateString('de-DE')}`
|
||||
: `Wurf #${l.id}`;
|
||||
// Wurf-Rang/-Name (aus der Wurfverwaltung) zuerst, dann Datum, dann #id
|
||||
const name = [l.wurf_rang ? `${l.wurf_rang}-Wurf` : null, l.wurf_name]
|
||||
.filter(Boolean).join(' · ');
|
||||
const label = name
|
||||
|| (l.geburt_datum ? `Wurf vom ${new Date(l.geburt_datum).toLocaleDateString('de-DE')}` : `Wurf #${l.id}`);
|
||||
const info = [
|
||||
l.welpen_gesamt ? `${l.welpen_gesamt} Welpen` : null,
|
||||
`${l.foto_count} Medien`,
|
||||
`${l.foto_count ?? 0} Medien`,
|
||||
].filter(Boolean).join(' · ');
|
||||
return `
|
||||
<div style="border:1px solid var(--c-border);border-radius:var(--radius-md);padding:var(--space-3)">
|
||||
|
|
@ -287,6 +321,36 @@ window.Page_breeder_editor = (() => {
|
|||
});
|
||||
|
||||
// Wurf-Upload
|
||||
// Zertifikat/Mitgliedschaft hochladen — Bezeichnung wird als Caption gespeichert
|
||||
el.querySelector('#be-cert-input')?.addEventListener('change', async e => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
const caption = (window.prompt('Bezeichnung (z. B. „VDH-Mitglied", „Club für Britische Hütehunde"):') || '').trim();
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
fd.append('entity_type', 'certificate');
|
||||
fd.append('entity_id', String(_data.profile.id));
|
||||
fd.append('visibility', 'public');
|
||||
if (caption) fd.append('caption', caption);
|
||||
try {
|
||||
const ph = await API.breederPhotos.upload(fd);
|
||||
(_data.profile.certificates ||= []).push(ph);
|
||||
UI.toast.success('Zertifikat hinzugefügt — erscheint auf deiner Profilseite.');
|
||||
_render();
|
||||
} catch (err) { UI.toast.error(err.message); }
|
||||
});
|
||||
|
||||
// Zertifikat löschen
|
||||
el.querySelectorAll('.be-cert-del').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
try {
|
||||
await API.breederPhotos.remove(parseInt(btn.dataset.id));
|
||||
_data.profile.certificates = (_data.profile.certificates || []).filter(c => String(c.id) !== btn.dataset.id);
|
||||
_render();
|
||||
} catch (err) { UI.toast.error(err.message); }
|
||||
});
|
||||
});
|
||||
|
||||
el.querySelectorAll('.be-litter-input').forEach(input => {
|
||||
input.addEventListener('change', async e => {
|
||||
const file = e.target.files[0];
|
||||
|
|
|
|||
|
|
@ -160,6 +160,25 @@ window.Page_breeder = (() => {
|
|||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
<!-- Mitgliedschaften & Zertifikate -->
|
||||
${p.zertifikate?.length ? `
|
||||
<div style="margin-bottom:var(--space-5)">
|
||||
<h2 style="margin:0 0 var(--space-3);font-size:var(--text-base);font-weight:700;
|
||||
display:flex;align-items:center;gap:var(--space-2)">
|
||||
${UI.icon('seal-check')} Mitgliedschaften & Zertifikate
|
||||
</h2>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:var(--space-3)">
|
||||
${p.zertifikate.map(z => `
|
||||
<a href="${UI.escape(z.url)}" target="_blank" rel="noopener"
|
||||
style="display:block;background:var(--c-surface);border:1px solid var(--c-border);
|
||||
border-radius:var(--radius-md);padding:var(--space-2);text-decoration:none;text-align:center">
|
||||
<img src="${UI.escape(z.thumbnail_url || z.url)}" alt="${UI.escape(z.caption || 'Zertifikat')}"
|
||||
loading="lazy" style="width:100%;aspect-ratio:1;object-fit:contain">
|
||||
${z.caption ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:var(--space-1);overflow:hidden;text-overflow:ellipsis">${UI.escape(z.caption)}</div>` : ''}
|
||||
</a>`).join('')}
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
<!-- Gesundheits-Transparenz -->
|
||||
${(p.hd_stats?.length || p.ed_stats?.length) ? `
|
||||
<div style="margin-bottom:var(--space-5)">
|
||||
|
|
@ -306,6 +325,8 @@ window.Page_breeder = (() => {
|
|||
const _STATUS_COLOR = { geplant: '#6b7280', geboren: '#3b82f6', verfuegbar: '#16a34a', abgeschlossen: '#9ca3af' };
|
||||
|
||||
function _wurfCard(w) {
|
||||
const wurfTitel = [w.wurf_rang ? `${w.wurf_rang}-Wurf` : null, w.wurf_name]
|
||||
.filter(Boolean).join(' · ');
|
||||
const eltern = [w.vater_name, w.mutter_name].filter(Boolean).join(' × ') || '—';
|
||||
const datum = w.geburt_datum
|
||||
? `Geburt: ${_fmtDate(w.geburt_datum)}`
|
||||
|
|
@ -316,6 +337,7 @@ window.Page_breeder = (() => {
|
|||
<div style="background:var(--c-surface);border:1px solid var(--c-border);border-radius:var(--radius-lg);
|
||||
padding:var(--space-3) var(--space-4)">
|
||||
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-1)">
|
||||
${wurfTitel ? `<span style="background:var(--c-primary);color:#fff;border-radius:999px;padding:1px 10px;font-size:var(--text-xs);font-weight:700">${UI.escape(wurfTitel)}</span>` : ''}
|
||||
<span style="font-weight:700;font-size:var(--text-sm)">${UI.escape(eltern)}</span>
|
||||
<span style="background:${sc}1a;color:${sc};border:1px solid ${sc}40;
|
||||
border-radius:999px;padding:1px 8px;font-size:var(--text-xs);font-weight:600">${sl}</span>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<script src="/js/landing-init.js?v=1269"></script>
|
||||
<script src="/js/landing-init.js?v=1270"></script>
|
||||
<title>Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz</title>
|
||||
<meta name="description" content="Ban Yaro: Die kostenlose All-in-One Hunde-App für DACH. Tagebuch, Giftköder-Alarm, Training mit KI, Forum, Wurfbörse, Stammbaum, Inzucht-Check — DSGVO-konform, offline-fähig, direkt im Browser.">
|
||||
<meta name="keywords" content="Hunde App, Hunde Community, Wurfbörse, Züchter, Welpen kaufen, Stammbaum Hund, Inzuchtkoeffizient, Hundezucht, Impfpass Hund, Giftköder Alarm, Gassi Community, Hundetraining App, Hunde Forum, Hunde KI, Hundefilm Datenbank, Welpen Marktplatz">
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
============================================================ */
|
||||
|
||||
// ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab
|
||||
const VER = '1269';
|
||||
const VER = '1270';
|
||||
const CACHE_VERSION = `by-v${VER}`;
|
||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue