STAMMBAUM-AVATAR: Tier-Foto als Avatar in Pedigree-Karte, GerbilIcon-Fallback (profilePhotoUrl-Contract)
This commit is contained in:
@@ -108,6 +108,7 @@ function gerbil(
|
||||
notes: null,
|
||||
// BESTAND-FILTER: default = eigener Bestand; einzelne externe Ahnen unten gesetzt.
|
||||
isResident: true,
|
||||
profilePhotoUrl: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +121,7 @@ export function seedDb(): MockDb {
|
||||
originContactId: 'con-meier',
|
||||
originBreeder: 'Clan-Kleine-Chaoten',
|
||||
},
|
||||
{ ...gerbil('fridolin', 'Fridolin', 'male', '2023-05-01', 'w-fridolin', 'cv-schwarz', 'aa CC DD EE GG PP spsp rere'), enclosureId: 'enc-gross', originBreeder: 'Zoohandlung Meier' },
|
||||
{ ...gerbil('fridolin', 'Fridolin', 'male', '2023-05-01', 'w-fridolin', 'cv-schwarz', 'aa CC DD EE GG PP spsp rere'), enclosureId: 'enc-gross', originBreeder: 'Zoohandlung Meier', profilePhotoUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' },
|
||||
gerbil('luna', 'Luna', 'female', '2023-08-15', 'w-luna', 'cv-gold', 'AA CC DD EE GG pp spsp rere'),
|
||||
gerbil('balu', 'Balu', 'male', '2021-04-20', 'w-balu', 'cv-agouti'),
|
||||
gerbil('maja', 'Maja', 'female', '2021-06-11', null, 'cv-schwarz-schecke', 'aa CC DD EE GG PP Spsp rere'),
|
||||
|
||||
@@ -137,3 +137,20 @@ test('Kein Würfe-Panel wenn Wurzeltier keine Würfe hat (STAMMBAUM-LITTERS)', a
|
||||
await expect(page.locator('.pedigree-card').first()).toBeVisible()
|
||||
await expect(page.locator('.stammbaum-litters-panel')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Tier mit Foto zeigt Avatar-Bild, Tier ohne Foto zeigt Rennmaus-Icon (STAMMBAUM-AVATAR)', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
// Fridolin hat profilePhotoUrl (data URI) → <img.pedigree-card__avatar> sichtbar
|
||||
// Krümel hat profilePhotoUrl=null → kein img, SVG-Icon sichtbar
|
||||
await page.goto('/rennmaeuse/kruemel/stammbaum')
|
||||
await expect(page.locator('.pedigree-card').first()).toBeVisible()
|
||||
|
||||
// Fridolin ist Vater von Krümel → seine Karte enthält ein <img>
|
||||
const fridolinCard = page.locator('.pedigree-card').filter({ hasText: 'Fridolin' })
|
||||
await expect(fridolinCard.locator('img.pedigree-card__avatar')).toBeVisible()
|
||||
|
||||
// Krümel (root) hat kein Foto → SVG-Icon im Foto-Slot
|
||||
const rootCard = page.locator('.pedigree-card.pedigree-card--root')
|
||||
await expect(rootCard.locator('img.pedigree-card__avatar')).not.toBeAttached()
|
||||
await expect(rootCard.locator('svg[role="img"]')).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -56,6 +56,8 @@ export interface Gerbil {
|
||||
isResident?: boolean
|
||||
/** FORM-FIELDS-1: true=gehörlos, false=hörend, null=unbekannt. */
|
||||
isDeaf?: boolean | null
|
||||
/** STAMMBAUM-AVATAR: URL des Profilfotos (erstes Foto nach sortOrder); null = kein Foto. API-relativ oder absolut. */
|
||||
profilePhotoUrl?: string | null
|
||||
}
|
||||
|
||||
/** Payload for POST /gerbils. */
|
||||
|
||||
35
gerbil-manager-web/src/components/GerbilIcon.tsx
Normal file
35
gerbil-manager-web/src/components/GerbilIcon.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
interface Props {
|
||||
size?: string | number
|
||||
style?: CSSProperties
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Reusable gerbil silhouette icon — upright sitting pose, long tail with tuft. fill=currentColor. */
|
||||
export default function GerbilIcon({ size = '1em', style, className }: Props) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 64 64"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
style={{ display: 'inline-block', verticalAlign: '-0.125em', flexShrink: 0, ...style }}
|
||||
className={className}
|
||||
role="img"
|
||||
aria-label="Rennmaus"
|
||||
>
|
||||
{/* body — sitting upright */}
|
||||
<path d="M40 14c6 0 11 5 11 12 0 4-2 7-2 11 0 10-6 16-15 16-8 0-14-6-14-15 0-13 9-24 20-24z" />
|
||||
{/* ear */}
|
||||
<circle cx="46" cy="15" r="5" />
|
||||
{/* hind paw */}
|
||||
<ellipse cx="24" cy="52" rx="9" ry="3.5" />
|
||||
{/* long curved tail with tuft */}
|
||||
<path d="M16 51c-7 1-13-2-15-9-1-4 0-8 3-10 1 3 1 6 3 8 3 3 7 4 11 4z" />
|
||||
{/* front paw */}
|
||||
<path d="M30 45c3 0 5 1 5 4-3 1-6 1-7-2 0-1 1-2 2-2z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -21,13 +21,14 @@ import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import Tree from 'react-d3-tree'
|
||||
import type { CustomNodeElementProps, Point, RawNodeDatum } from 'react-d3-tree'
|
||||
import { de } from '../strings/de'
|
||||
import { ApiError } from '../api/client'
|
||||
import { API_BASE_URL, ApiError } from '../api/client'
|
||||
import { listLitters } from '../api/litters'
|
||||
import { listColorVarieties } from '../api/lookups'
|
||||
import { getInbreedingCoefficient } from '../api/pedigree'
|
||||
import type { Gender, Gerbil, Litter } from '../api/types'
|
||||
import { useApi } from '../hooks/useApi'
|
||||
import { formatDate, genderLabel } from '../format/labels'
|
||||
import GerbilIcon from '../components/GerbilIcon'
|
||||
import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag, toDisplayString } from '../genetics'
|
||||
import {
|
||||
DEFAULT_GENERATIONS,
|
||||
@@ -380,6 +381,14 @@ function PedigreeCard({
|
||||
const g = node.gerbil
|
||||
const chip = farbschlag ? chipColorFor(farbschlag) : null
|
||||
const year = g.dateOfBirth ? g.dateOfBirth.slice(0, 4) : 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
|
||||
return (
|
||||
<div
|
||||
className={isRoot ? 'pedigree-card pedigree-card--root' : 'pedigree-card'}
|
||||
@@ -387,9 +396,17 @@ function PedigreeCard({
|
||||
role={isRoot ? undefined : 'button'}
|
||||
title={isRoot ? undefined : t.tapHint}
|
||||
>
|
||||
{/* Foto-Platzhalter — echte Fotos kommen mit FEAT-6. */}
|
||||
<div className="pedigree-card__photo" aria-hidden="true">
|
||||
🐭
|
||||
<div className="pedigree-card__photo" aria-hidden={!photoUrl || undefined}>
|
||||
{photoUrl ? (
|
||||
<img
|
||||
src={photoUrl}
|
||||
alt={g.name || ''}
|
||||
className="pedigree-card__avatar"
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
) : (
|
||||
<GerbilIcon size="1.6rem" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
<div className="pedigree-card__body">
|
||||
<div className="pedigree-card__name">
|
||||
|
||||
@@ -236,6 +236,7 @@
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: var(--color-accent-soft);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -243,6 +244,13 @@
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.pedigree-card__avatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.pedigree-card__body {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user