Merge JUVENILE-AVATARS (feature/juvenile-avatars @299c038) — Kelly
Some checks failed
CI / Frontend Tests (Node/Vite) (push) Successful in 9m32s
CI / Docker Build & Push (push) Failing after 9s
CI / Backend Tests (.NET) (push) Successful in 1m3s

Foto-Avatare pro Jungtier im Wurf-Detail (36px Kreis-Avatar + GerbilIcon-Fallback),
reuse profilePhotoUrl + failedUrl-Fehler-Tracking. StammbaumPage auf gleiches
failedUrl-Muster umgestellt (set-state-in-effect Lint-Fix). FE-only, keine Migration.
god-QA: vitest 122/122, build+tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 08:24:49 +02:00
5 changed files with 110 additions and 21 deletions

View File

@@ -1,4 +1,4 @@
/** LITTER-JUVENILE-DETAILS: Jungtier-Felder (Farbschlag/Abgabe/Verstorben) im Wurf-Detail. */ /** LITTER-JUVENILE-DETAILS + JUVENILE-AVATARS: Jungtier-Felder und Foto-Avatare im Wurf-Detail. */
import { de, expect, skipUnlessMock, test } from './fixtures' import { de, expect, skipUnlessMock, test } from './fixtures'
const t = de.pages.litters const t = de.pages.litters
@@ -28,3 +28,18 @@ test('Jungtier-Liste zeigt Farbschlag, Abgabedatum, Abnehmer, Todesdatum und Tod
await expect(bennyCard).toContainText(t.detail.juvenileFields.dateOfDeath) await expect(bennyCard).toContainText(t.detail.juvenileFields.dateOfDeath)
await expect(bennyCard).toContainText(`${t.detail.juvenileFields.causeOfDeath}: Altersschwäche`) await expect(bennyCard).toContainText(`${t.detail.juvenileFields.causeOfDeath}: Altersschwäche`)
}) })
test('JUVENILE-AVATARS: Pippa zeigt Foto-Avatar, Benny zeigt GerbilIcon', async ({ page }) => {
skipUnlessMock()
await page.goto('/wuerfe/w-kruemel')
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
// Pippa hat profilePhotoUrl → <img.juvenile-photo__avatar> sichtbar
const pippaCard = page.getByRole('link', { name: /Pippa/ })
await expect(pippaCard.locator('.juvenile-photo__avatar')).toBeVisible()
// Benny hat kein Foto → kein img, SVG-Icon (GerbilIcon) sichtbar
const bennyCard = page.getByRole('link', { name: /Benny/ })
await expect(bennyCard.locator('.juvenile-photo__avatar')).not.toBeVisible()
await expect(bennyCard.locator('svg')).toBeVisible()
})

View File

@@ -153,11 +153,13 @@ export function seedDb(): MockDb {
notes: 'neugieriger Entdecker', notes: 'neugieriger Entdecker',
}, },
// LITTER-JUVENILE-DETAILS: Jungtiere für Wurf K mit Zusatzinfos. // LITTER-JUVENILE-DETAILS: Jungtiere für Wurf K mit Zusatzinfos.
// JUVENILE-AVATARS: Pippa hat profilePhotoUrl (1×1-PNG data-URI) → Avatar-Img sichtbar.
{ {
...gerbil('pup-abgabe', 'Pippa', 'female', '2025-03-12', 'w-kruemel', 'cv-gold'), ...gerbil('pup-abgabe', 'Pippa', 'female', '2025-03-12', 'w-kruemel', 'cv-gold'),
status: 'GivenAway' as const, status: 'GivenAway' as const,
goHomeDate: '2025-05-01', goHomeDate: '2025-05-01',
receiverContactId: 'con-huber', receiverContactId: 'con-huber',
profilePhotoUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
}, },
{ {
...gerbil('pup-verstorben', 'Benny', 'male', '2025-03-12', 'w-kruemel', null), ...gerbil('pup-verstorben', 'Benny', 'male', '2025-03-12', 'w-kruemel', null),

View File

@@ -381,14 +381,13 @@ function PedigreeCard({
const g = node.gerbil const g = node.gerbil
const chip = farbschlag ? chipColorFor(farbschlag) : null const chip = farbschlag ? chipColorFor(farbschlag) : null
const dob = g.dateOfBirth ? formatDate(g.dateOfBirth) : null const dob = g.dateOfBirth ? formatDate(g.dateOfBirth) : null
const [imgError, setImgError] = useState(false) const [failedUrl, setFailedUrl] = useState<string | null>(null)
useEffect(() => setImgError(false), [g.id]) const rawUrl = g.profilePhotoUrl
const photoUrl = ? /^(https?:|data:|\/\/)/.test(g.profilePhotoUrl)
!imgError && g.profilePhotoUrl ? g.profilePhotoUrl
? /^(https?:|data:|\/\/)/.test(g.profilePhotoUrl) : `${API_BASE_URL}${g.profilePhotoUrl}`
? g.profilePhotoUrl : null
: `${API_BASE_URL}${g.profilePhotoUrl}` const photoUrl = rawUrl !== failedUrl ? rawUrl : null
: null
return ( return (
<div <div
className={isRoot ? 'pedigree-card pedigree-card--root' : 'pedigree-card'} className={isRoot ? 'pedigree-card pedigree-card--root' : 'pedigree-card'}
@@ -402,7 +401,7 @@ function PedigreeCard({
src={photoUrl} src={photoUrl}
alt={g.name || ''} alt={g.name || ''}
className="pedigree-card__avatar" className="pedigree-card__avatar"
onError={() => setImgError(true)} onError={() => setFailedUrl(rawUrl)}
/> />
) : ( ) : (
<GerbilIcon size="1.6rem" aria-hidden="true" /> <GerbilIcon size="1.6rem" aria-hidden="true" />

View File

@@ -1,15 +1,62 @@
import { useCallback, useMemo } from 'react' import { useCallback, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import { de } from '../strings/de' import { de } from '../strings/de'
import { getLitter } from '../api/litters' import { getLitter } from '../api/litters'
import { getGerbil, listGerbils } from '../api/gerbils' import { getGerbil, listGerbils } from '../api/gerbils'
import { listColorVarieties, listContacts } from '../api/lookups' import { listColorVarieties, listContacts } from '../api/lookups'
import { API_BASE_URL } from '../api/client'
import { condition } from '../api/gridify' import { condition } from '../api/gridify'
import { type Gerbil } from '../api/types'
import { useApi } from '../hooks/useApi' import { useApi } from '../hooks/useApi'
import { formatDate, genderLabel } from '../format/labels' import { formatDate, genderLabel } from '../format/labels'
import { isValidGenotype } from '../format/genotypeText' import { isValidGenotype } from '../format/genotypeText'
import { breed, fromDisplayString, genotypeToFarbschlag, UNKNOWN_FARBSCHLAG, type BreedingResult } from '../genetics' import { breed, fromDisplayString, genotypeToFarbschlag, UNKNOWN_FARBSCHLAG, type BreedingResult } from '../genetics'
import BreedingResultView from '../components/BreedingResultView' import BreedingResultView from '../components/BreedingResultView'
import GerbilIcon from '../components/GerbilIcon'
import './wuerfe.css'
type JuvenileFields = (typeof de.pages.litters.detail.juvenileFields)
function JuvenileCard({ g, farbe, receiver, tJf }: {
g: Gerbil
farbe: string | null
receiver: string | null
tJf: JuvenileFields
}) {
const [failedUrl, setFailedUrl] = useState<string | null>(null)
const rawUrl = g.profilePhotoUrl
? /^(https?:|data:|\/\/)/.test(g.profilePhotoUrl)
? g.profilePhotoUrl
: `${API_BASE_URL}${g.profilePhotoUrl}`
: null
const photoUrl = rawUrl !== failedUrl ? rawUrl : null
return (
<Link to={`/rennmaeuse/${g.id}`} className="gerbil-card gerbil-card--with-photo">
<div className="juvenile-photo">
{photoUrl ? (
<img
src={photoUrl}
alt={g.name || ''}
className="juvenile-photo__avatar"
onError={() => setFailedUrl(rawUrl)}
/>
) : (
<GerbilIcon size="1.4rem" aria-hidden="true" />
)}
</div>
<div className="juvenile-body">
<span className="gerbil-card__name">{g.name}</span>
<span className="gerbil-card__meta">{genderLabel(g.gender)}</span>
{farbe && <span className="gerbil-card__meta">{tJf.colorVariety}: {farbe}</span>}
{g.goHomeDate && <span className="gerbil-card__meta">{tJf.goHomeDate}: {formatDate(g.goHomeDate)}</span>}
{receiver && <span className="gerbil-card__meta">{tJf.receiver}: {receiver}</span>}
{g.dateOfDeath && <span className="gerbil-card__meta">{tJf.dateOfDeath}: {formatDate(g.dateOfDeath)}</span>}
{g.causeOfDeath && <span className="gerbil-card__meta">{tJf.causeOfDeath}: {g.causeOfDeath}</span>}
</div>
</Link>
)
}
export default function WurfDetailPage() { export default function WurfDetailPage() {
const t = de.pages.litters const t = de.pages.litters
@@ -158,19 +205,11 @@ export default function WurfDetailPage() {
<ul className="card-list"> <ul className="card-list">
{(juveniles.data?.items ?? []).map((g) => { {(juveniles.data?.items ?? []).map((g) => {
const farbe = farbschlagOf(g) const farbe = farbschlagOf(g)
const receiver = g.receiverContactId ? contactNameById.get(g.receiverContactId) : null const receiver = g.receiverContactId ? contactNameById.get(g.receiverContactId) ?? null : null
const tJf = t.detail.juvenileFields const tJf = t.detail.juvenileFields
return ( return (
<li key={g.id}> <li key={g.id}>
<Link to={`/rennmaeuse/${g.id}`} className="gerbil-card"> <JuvenileCard g={g} farbe={farbe} receiver={receiver} tJf={tJf} />
<span className="gerbil-card__name">{g.name}</span>
<span className="gerbil-card__meta">{genderLabel(g.gender)}</span>
{farbe && <span className="gerbil-card__meta">{tJf.colorVariety}: {farbe}</span>}
{g.goHomeDate && <span className="gerbil-card__meta">{tJf.goHomeDate}: {formatDate(g.goHomeDate)}</span>}
{receiver && <span className="gerbil-card__meta">{tJf.receiver}: {receiver}</span>}
{g.dateOfDeath && <span className="gerbil-card__meta">{tJf.dateOfDeath}: {formatDate(g.dateOfDeath)}</span>}
{g.causeOfDeath && <span className="gerbil-card__meta">{tJf.causeOfDeath}: {g.causeOfDeath}</span>}
</Link>
</li> </li>
) )
})} })}

View File

@@ -0,0 +1,34 @@
/* JUVENILE-AVATARS: photo avatar to the left of each juvenile card. */
.gerbil-card--with-photo {
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.juvenile-body {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.juvenile-photo {
flex: none;
width: 36px;
height: 36px;
border-radius: 50%;
overflow: hidden;
background: var(--color-accent-soft);
display: flex;
align-items: center;
justify-content: center;
align-self: center;
}
.juvenile-photo__avatar {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}