Merge JUVENILE-AVATARS (feature/juvenile-avatars @299c038) — Kelly
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:
@@ -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()
|
||||||
|
})
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
34
gerbil-manager-web/src/pages/wuerfe.css
Normal file
34
gerbil-manager-web/src/pages/wuerfe.css
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user