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'
|
||||
|
||||
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.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',
|
||||
},
|
||||
// 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'),
|
||||
status: 'GivenAway' as const,
|
||||
goHomeDate: '2025-05-01',
|
||||
receiverContactId: 'con-huber',
|
||||
profilePhotoUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
|
||||
},
|
||||
{
|
||||
...gerbil('pup-verstorben', 'Benny', 'male', '2025-03-12', 'w-kruemel', null),
|
||||
|
||||
@@ -381,14 +381,13 @@ function PedigreeCard({
|
||||
const g = node.gerbil
|
||||
const chip = farbschlag ? chipColorFor(farbschlag) : null
|
||||
const dob = g.dateOfBirth ? formatDate(g.dateOfBirth) : null
|
||||
const [imgError, setImgError] = useState(false)
|
||||
useEffect(() => setImgError(false), [g.id])
|
||||
const photoUrl =
|
||||
!imgError && g.profilePhotoUrl
|
||||
? /^(https?:|data:|\/\/)/.test(g.profilePhotoUrl)
|
||||
? g.profilePhotoUrl
|
||||
: `${API_BASE_URL}${g.profilePhotoUrl}`
|
||||
: null
|
||||
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 (
|
||||
<div
|
||||
className={isRoot ? 'pedigree-card pedigree-card--root' : 'pedigree-card'}
|
||||
@@ -402,7 +401,7 @@ function PedigreeCard({
|
||||
src={photoUrl}
|
||||
alt={g.name || ''}
|
||||
className="pedigree-card__avatar"
|
||||
onError={() => setImgError(true)}
|
||||
onError={() => setFailedUrl(rawUrl)}
|
||||
/>
|
||||
) : (
|
||||
<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 { de } from '../strings/de'
|
||||
import { getLitter } from '../api/litters'
|
||||
import { getGerbil, listGerbils } from '../api/gerbils'
|
||||
import { listColorVarieties, listContacts } from '../api/lookups'
|
||||
import { API_BASE_URL } from '../api/client'
|
||||
import { condition } from '../api/gridify'
|
||||
import { type Gerbil } from '../api/types'
|
||||
import { useApi } from '../hooks/useApi'
|
||||
import { formatDate, genderLabel } from '../format/labels'
|
||||
import { isValidGenotype } from '../format/genotypeText'
|
||||
import { breed, fromDisplayString, genotypeToFarbschlag, UNKNOWN_FARBSCHLAG, type BreedingResult } from '../genetics'
|
||||
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() {
|
||||
const t = de.pages.litters
|
||||
@@ -158,19 +205,11 @@ export default function WurfDetailPage() {
|
||||
<ul className="card-list">
|
||||
{(juveniles.data?.items ?? []).map((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
|
||||
return (
|
||||
<li key={g.id}>
|
||||
<Link to={`/rennmaeuse/${g.id}`} className="gerbil-card">
|
||||
<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>
|
||||
<JuvenileCard g={g} farbe={farbe} receiver={receiver} tJf={tJf} />
|
||||
</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