feat: Rennmausakte, Zucht-Suffix, Gehege-Bilder, Toasts, Infinite-Scroll

Detailseite (Rennmausakte):
- Neugestaltung: Hero-Foto + Overlay, Schnellfakten-Pills, Karten-Sektionen,
  Charakter als Chips, getabte Fotos/Gesundheit/Gewicht.
- Eltern (Vater/Mutter) als Links; Charakter-Karte klappt bei Status
  Abgabe/Verstorben/Abgegeben ein (nur Zucht/Liebhaber offen).
- Gehege wird bei abgegebenen/verstorbenen Tieren ausgeblendet (Detail + Formular).

Listen:
- Infinite Scroll auf allen Listen (Rennmäuse, Würfe, Gehege, Verträge,
  Anfragen, Kontakte) via useInfiniteList/useInfiniteSentinel; stabile
  Sortierung mit id-Tiebreaker (keine doppelten Keys), Back-to-top-Button.
- Kontakte: Rolle-Filter (Züchter/Abnehmer) als Quick-Chips + Sticky-Header.

Zucht-Nachname:
- Namens-Anhängsel der Zucht in den Einstellungen + je Züchter-Kontakt
  (Backend-Spalten + Migration); eigene Tiere zeigen „Name + Suffix".
- „Eigene Zucht" ist die Standard-Herkunft neuer Tiere.

Weiteres:
- Gehege-Bilder: Upload/Galerie auf der Gehege-Detailseite (Backend
  EnclosurePhoto + Endpoints + Migration, geteilte Dateiablage).
- Toast-Rückmeldungen für alle Speichern-Aktionen.
- Checkboxen durch mobile-freundliche Toggle-Schalter ersetzt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-13 13:35:30 +02:00
parent bddbaf59d3
commit b83e96f552
48 changed files with 4601 additions and 300 deletions

View File

@@ -7,12 +7,14 @@ import type { Gerbil } from '../api/types'
import { useApi } from '../hooks/useApi'
import { fromDisplayString, genotypeToFarbschlag } from '../genetics'
import GroupComposer from '../components/GroupComposer'
import { useGerbilName } from '../components/breederSuffix'
import './abgabe.css'
const UNGROUPED = 'ungrouped'
export default function AbgabePage() {
const t = de.pages.abgabe
const gerbilName = useGerbilName()
const forSale = useApi(
() =>
@@ -82,7 +84,7 @@ export default function AbgabePage() {
<ul className="abgabe-roster__list">
{animals.map((a) => (
<li key={a.id}>
<span>{a.name}</span>
<span>{gerbilName(a) || a.name}</span>
<select
value={assignment(a)}
onChange={(e) => moveTo(a.id, e.target.value)}

View File

@@ -12,6 +12,7 @@ import { listGerbils } from '../api/gerbils'
import { listColorVarieties } from '../api/lookups'
import { condition } from '../api/gridify'
import { useApi, useMutation } from '../hooks/useApi'
import EnclosurePhotosSection from '../components/EnclosurePhotosSection'
export default function BeckenDetailPage() {
const t = de.pages.becken
@@ -109,6 +110,9 @@ export default function BeckenDetailPage() {
</dl>
)}
<h3>{t.photosTitle}</h3>
<EnclosurePhotosSection enclosureId={e.id} />
<h3>{t.occupancy.title}</h3>
{occupants.loading && <p className="muted">{de.common.loading}</p>}
{occupants.error && (

View File

@@ -4,6 +4,7 @@ import { Link, useNavigate, useParams } from 'react-router-dom'
import { de } from '../strings/de'
import { createEnclosure, getEnclosure, updateEnclosure, type CreateEnclosure } from '../api/enclosures'
import { useApi, useMutation } from '../hooks/useApi'
import { useToast } from '../components/toast'
interface FormState {
name: string
@@ -18,6 +19,7 @@ const nn = (s: string): string | null => (s.trim() === '' ? null : s)
export default function BeckenFormPage() {
const t = de.pages.becken
const navigate = useNavigate()
const toast = useToast()
const { id } = useParams()
const isEdit = Boolean(id)
@@ -45,8 +47,13 @@ export default function BeckenFormPage() {
}
setErrors({})
const result = await mutation.run({ name: form.name.trim(), notes: nn(form.notes) })
if (result.ok) navigate(`/gehege/${result.value.id}`)
// On failure mutation.error drives the inline alert; run() never throws.
if (result.ok) {
toast.success(de.common.saved)
navigate(`/gehege/${result.value.id}`)
} else {
toast.error(result.error)
}
// On failure mutation.error also drives the inline alert; run() never throws.
}
if (isEdit && existing.loading) return <p className="muted">{de.common.loading}</p>

View File

@@ -13,12 +13,14 @@ import {
type BreederProfile,
} from '../api/settings'
import { useApi, useMutation } from '../hooks/useApi'
import { useToast } from '../components/toast'
// EXPORT-1 (Oscar): Datenexport-Karte
import DatenexportCard from '../components/DatenexportCard'
export default function EinstellungenPage() {
const t = de.pages.einstellungen
const tz = t.zuchtprofil
const toast = useToast()
const [form, setForm] = useState<BreederProfile>(EMPTY_BREEDER_PROFILE)
const [initialized, setInitialized] = useState(false)
@@ -40,7 +42,12 @@ export default function EinstellungenPage() {
async function onSubmit(e: FormEvent) {
e.preventDefault()
const result = await mutation.run(form)
if (result.ok) setSaved(true)
if (result.ok) {
setSaved(true)
toast.success(de.common.saved)
} else {
toast.error(result.error)
}
}
if (existing.loading) return <p className="muted">{de.common.loading}</p>
@@ -81,6 +88,7 @@ export default function EinstellungenPage() {
<form className="form" onSubmit={onSubmit} noValidate>
{field('zuchtName', tz.fields.zuchtName)}
{field('nameSuffix', tz.fields.nameSuffix, tz.fields.nameSuffixHint)}
{field('name', tz.fields.name, tz.fields.nameHint)}
{field('address', tz.fields.address, tz.fields.addressHint)}
{field('city', tz.fields.city)}

View File

@@ -6,17 +6,30 @@ import { listLitters as listLittersPaged } from '../api/litters'
import { listColorVarieties, listContacts, listEnclosures, listLitters } from '../api/lookups'
import { useApi, useMutation } from '../hooks/useApi'
import { formatDate, genderLabel, statusLabel } from '../format/labels'
import { ALL_TRAITS, TRAIT_CATEGORIES } from '../format/traits'
import { fromDisplayString, genotypeToFarbschlag, displayGenotypeSafe } from '../genetics'
import type { Gender, GerbilStatus } from '../api/types'
import FarbschlagImage from '../components/FarbschlagImage'
import Charakterbogen from '../components/Charakterbogen'
// FEAT-6 (Oscar): Tab-Inhalte + Profilfoto
import GerbilHealthTab from '../components/GerbilHealthTab'
import GerbilPhotosTab from '../components/GerbilPhotosTab'
import GerbilProfilePhoto from '../components/GerbilProfilePhoto'
import GerbilWeightTab from '../components/GerbilWeightTab'
import { useGerbilName } from '../components/breederSuffix'
import { useToast } from '../components/toast'
import './rennmausakte.css'
type DetailTab = 'photos' | 'health' | 'weight'
// DESIGN (Rennmausakte): per-status colours for the hero status badge.
const STATUS_COLORS: Record<GerbilStatus, string> = {
Breeding: '#5e8c5a',
Pet: '#5a7da8',
ForSale: '#c2703d',
Deceased: '#8a847b',
GivenAway: '#b08e5c',
}
const GENDER_SYMBOL: Record<Gender, string> = { unknown: '?', male: '♂', female: '♀' }
/** Parse a stored genotype string and derive its normalized form + Farbschlag. */
function describeGenotype(genotype: string | null): { display: string; farbschlag: string } | null {
if (!genotype || !genotype.trim()) return null
@@ -24,22 +37,24 @@ function describeGenotype(genotype: string | null): { display: string; farbschla
const g = fromDisplayString(genotype)
return { display: displayGenotypeSafe(genotype), farbschlag: genotypeToFarbschlag(g) }
} catch {
// Unparseable: show the safely-rendered raw value, no derived Farbschlag.
return { display: displayGenotypeSafe(genotype), farbschlag: de.genetics.unknownFarbschlag }
}
}
function Row({ label, value }: { label: string; value: ReactNode }) {
/** A labelled key/value row inside a card (semantic dt/dd, styled as a grid). */
function Kv({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="def-row">
<dt>{label}</dt>
<dd>{value ?? '—'}</dd>
<div className="ak-kv">
<dt className="ak-kv-label">{label}</dt>
<dd className="ak-kv-value">{children}</dd>
</div>
)
}
export default function GerbilDetailPage() {
const t = de.pages.gerbils
const gerbilName = useGerbilName()
const toast = useToast()
const { id = '' } = useParams()
const [tab, setTab] = useState<DetailTab>('photos')
@@ -49,7 +64,9 @@ export default function GerbilDetailPage() {
const [charTraits, setCharTraits] = useState<string[]>([])
const [charNote, setCharNote] = useState('')
const [charInit, setCharInit] = useState<string | null>(null)
const [isCharacterExpanded, setIsCharacterExpanded] = useState(true)
// Charakter ist v. a. für lebende Bestandstiere relevant → bei Zucht/Liebhaber
// aufgeklappt, sonst (Abgabe/Verstorben/Abgegeben) eingeklappt (manuell öffenbar).
const [charExpanded, setCharExpanded] = useState(true)
const saveCharacter = useMutation(() =>
updateGerbil(id, { characterTraits: charTraits, characterNote: charNote.trim() || null }),
)
@@ -79,11 +96,26 @@ export default function GerbilDetailPage() {
[litters.data],
)
const receiverContact = useMemo(() => {
const receiverId = gerbil.data?.receiverContactId
if (!receiverId || !contacts.data) return null
return contacts.data.find((c) => c.id === receiverId) ?? null
}, [gerbil.data?.receiverContactId, contacts.data])
// Parents come from the gerbil's own litter (litterId → litter.fatherId/motherId).
// Fetch each parent gerbil for its name + link (mirrors WurfDetailPage).
const ownLitterId = gerbil.data?.litterId ?? null
const ownLitter = useMemo(
() => (ownLitterId ? ((litters.data ?? []).find((l) => l.id === ownLitterId) ?? null) : null),
[litters.data, ownLitterId],
)
const fatherId = ownLitter?.fatherId ?? null
const motherId = ownLitter?.motherId ?? null
const father = useApi(() => (fatherId ? getGerbil(fatherId) : Promise.resolve(null)), [fatherId])
const mother = useApi(() => (motherId ? getGerbil(motherId) : Promise.resolve(null)), [motherId])
// Character chips — toggle while preserving the catalog order (stable output).
const selectedTraits = new Set(charTraits)
const toggleTrait = (key: string) => {
const next = new Set(selectedTraits)
if (next.has(key)) next.delete(key)
else next.add(key)
setCharTraits(ALL_TRAITS.filter((tr) => next.has(tr.key)).map((tr) => tr.key))
}
if (gerbil.loading) return <p className="muted">{de.common.loading}</p>
if (gerbil.error || !gerbil.data) {
@@ -98,244 +130,329 @@ export default function GerbilDetailPage() {
}
const g = gerbil.data
const showEnclosure = g.status !== 'Deceased' && (
g.status !== 'GivenAway' ||
(receiverContact ? (receiverContact.isBreeder || receiverContact.isReceiver) : false)
)
// Abgegebene/verstorbene Tiere sind nicht mehr im eigenen Bestand → kein Gehege.
const showEnclosure = g.status !== 'Deceased' && g.status !== 'GivenAway'
const geno = describeGenotype(g.genotype)
const lookup = (map: Map<string, string>, key: string | null) => (key ? (map.get(key) ?? '—') : '—')
const storedColorName = g.colorVarietyId ? (colorName.get(g.colorVarietyId) ?? null) : null
// Seed the Charakterbogen once from the loaded gerbil (adjust-state-during-render).
const parentLink = (
pid: string | null,
p: Parameters<typeof gerbilName>[0] | null,
): ReactNode =>
pid && p ? (
<Link to={`/rennmaeuse/${pid}`}>{gerbilName(p) || de.pages.gerbils.nameless}</Link>
) : (
de.pages.litters.detail.unknownParent
)
// Seed the character state once from the loaded gerbil (adjust-state-during-render).
if (charInit !== g.id) {
setCharInit(g.id)
setCharTraits(g.characterTraits ?? [])
setCharNote(g.characterNote ?? '')
setIsCharacterExpanded(g.status !== 'Deceased' && g.status !== 'GivenAway')
setCharExpanded(g.status === 'Breeding' || g.status === 'Pet')
}
return (
<section className="page">
<header className="page-head">
<div className="profile-header-summary">
<GerbilProfilePhoto gerbilId={g.id} />
<div className="profile-header-info">
<h2>{g.name || de.pages.gerbils.nameless}</h2>
<div className="profile-header-badges">
<span className={`badge badge--${g.status.toLowerCase()}`}>{statusLabel(g.status)}</span>
{g.isResident === false && (
<span className="badge badge--external" title={de.pages.gerbils.externalTitle}>
{de.pages.gerbils.externalBadge}
</span>
)}
<section className="ak">
<div className="ak-hero">
<GerbilProfilePhoto
gerbilId={g.id}
className="ak-herophoto"
fallback={
<div className="ak-herophoto ak-herophoto--empty" aria-hidden="true">
🐭
</div>
</div>
}
/>
<div className="ak-herobar">
<Link to="/rennmaeuse" className="ak-back" aria-label={t.detail.back}>
</Link>
</div>
<div className="head-actions">
<Link to={`/rennmaeuse/${g.id}/bearbeiten`} className="btn btn--primary">
<div className="ak-heroover">
<h1 className="ak-name">{gerbilName(g) || de.pages.gerbils.nameless}</h1>
<span className="ak-statusbadge" style={{ color: STATUS_COLORS[g.status] }}>
{statusLabel(g.status)}
</span>
{g.isResident === false && (
<span className="ak-badge-extern" title={de.pages.gerbils.externalTitle}>
{de.pages.gerbils.externalBadge}
</span>
)}
</div>
</div>
<div className="ak-content">
<div className="ak-facts">
<span className="ak-fact">
<span aria-hidden="true">{GENDER_SYMBOL[g.gender]}</span>
{genderLabel(g.gender)}
{g.isCastrated ? ' (Kastrat)' : ''}
</span>
{g.dateOfBirth && (
<span className="ak-fact">
<span aria-hidden="true">🎂</span>
{formatDate(g.dateOfBirth)}
</span>
)}
{storedColorName && (
<span className="ak-fact">
<span aria-hidden="true">🎨</span>
{storedColorName}
</span>
)}
</div>
<div className="ak-actions">
<Link to={`/rennmaeuse/${g.id}/bearbeiten`} className="ak-btn primary">
{t.detail.edit}
</Link>
<Link to={`/genetik?parent=${g.id}`} className="btn">
<Link to={`/genetik?parent=${g.id}`} className="ak-btn">
{t.detail.testMating}
</Link>
<Link to={`/rennmaeuse/${g.id}/stammbaum`} className="ak-btn">
{de.pages.stammbaum.openButton}
</Link>
{g.status !== 'ForSale' && g.status !== 'Deceased' && (
<button
type="button"
className="btn"
className="ak-btn"
disabled={forSale.pending}
onClick={async () => {
const r = await forSale.run()
if (r.ok) gerbil.reload()
if (r.ok) {
toast.success(de.common.saved)
gerbil.reload()
} else {
toast.error(r.error)
}
}}
>
{de.pages.abgabe.markAction}
</button>
)}
<Link to={`/rennmaeuse/${g.id}/stammbaum`} className="btn">
{de.pages.stammbaum.openButton}
</Link>
{/* FEAT-13: Abgabe abschließen — Vertrag-Assistent mit diesem Tier vorausgewählt. */}
{g.status !== 'Deceased' && g.status !== 'GivenAway' && (
<Link to={`/vertraege/neu?tiere=${g.id}`} className="btn">
<Link to={`/vertraege/neu?tiere=${g.id}`} className="ak-btn">
{de.pages.vertraege.wizard.title}
</Link>
)}
<Link to="/rennmaeuse" className="btn">
<Link to="/rennmaeuse" className="ak-btn">
{t.detail.back}
</Link>
</div>
</header>
<h3>{t.detail.masterData}</h3>
<dl className="def-list">
<Row
label={t.fields.gender}
value={
<>
<section className="ak-card">
<h2 className="ak-h2">{t.detail.masterData}</h2>
<dl className="ak-kvlist">
<Kv label={t.fields.gender}>
{genderLabel(g.gender)}
{g.isCastrated && <span style={{ marginLeft: '0.5rem', opacity: 0.8 }}>(Kastrat)</span>}
</>
}
/>
<Row label={t.fields.status} value={statusLabel(g.status)} />
<Row label={t.fields.dateOfBirth} value={formatDate(g.dateOfBirth)} />
{g.status === 'Deceased' && (
<>
<Row label={t.fields.dateOfDeath} value={formatDate(g.dateOfDeath)} />
<Row label={t.fields.causeOfDeath} value={g.causeOfDeath || null} />
</>
)}
{g.status === 'GivenAway' && (
<Row label={t.fields.goHomeDate} value={formatDate(g.goHomeDate)} />
)}
<Row
label={t.fields.colorVariety}
value={
storedColorName ? (
<span className="farbschlag-value">
<FarbschlagImage name={storedColorName} size={32} />
{storedColorName}
</span>
) : (
'—'
)
}
/>
{g.spottingType && (
<Row
label={t.fields.spottingType}
value={
de.pages.gerbils.spottingTypes[g.spottingType as keyof typeof de.pages.gerbils.spottingTypes] ||
g.spottingType
}
/>
)}
{showEnclosure && (
<Row label={t.fields.enclosure} value={lookup(enclosureName, g.enclosureId)} />
)}
<Row
label={t.fields.litter}
value={
g.litterId && litterName.has(g.litterId)
? <Link to={`/wuerfe/${g.litterId}`}>{litterName.get(g.litterId)}</Link>
: lookup(litterName, g.litterId)
}
/>
<Row
label={t.fields.origin}
value={
g.originContactId && contactName.has(g.originContactId)
? <Link to={`/kontakte/${g.originContactId}`}>{contactName.get(g.originContactId)}</Link>
: (g.originBreeder || null)
}
/>
{g.status !== 'Deceased' && (
<Row label={t.fields.receiver} value={lookup(contactName, g.receiverContactId)} />
)}
<Row label={t.fields.notes} value={g.notes || null} />
</dl>
<h3>{t.detail.genetics}</h3>
{geno ? (
<dl className="def-list">
<Row label={t.fields.genotype} value={<code>{geno.display}</code>} />
<Row
label={t.detail.resolvedPrefix}
value={
{g.isCastrated && <span style={{ marginLeft: '0.4rem', opacity: 0.8 }}>(Kastrat)</span>}
</Kv>
<Kv label={t.fields.status}>{statusLabel(g.status)}</Kv>
<Kv label={t.fields.dateOfBirth}>{formatDate(g.dateOfBirth)}</Kv>
{g.status === 'Deceased' && (
<>
<Kv label={t.fields.dateOfDeath}>{formatDate(g.dateOfDeath)}</Kv>
<Kv label={t.fields.causeOfDeath}>{g.causeOfDeath || '—'}</Kv>
</>
)}
{g.status === 'GivenAway' && (
<Kv label={t.fields.goHomeDate}>{formatDate(g.goHomeDate)}</Kv>
)}
<Kv label={t.fields.colorVariety}>
{storedColorName ? (
<>
<FarbschlagImage name={storedColorName} size={22} />
{storedColorName}
</>
) : (
'—'
)}
</Kv>
{g.spottingType && (
<Kv label={t.fields.spottingType}>
{de.pages.gerbils.spottingTypes[
g.spottingType as keyof typeof de.pages.gerbils.spottingTypes
] || g.spottingType}
</Kv>
)}
{showEnclosure && (
<Kv label={t.fields.enclosure}>{lookup(enclosureName, g.enclosureId)}</Kv>
)}
<Kv label={t.fields.litter}>
{g.litterId && litterName.has(g.litterId) ? (
<Link to={`/wuerfe/${g.litterId}`}>{litterName.get(g.litterId)}</Link>
) : (
lookup(litterName, g.litterId)
)}
</Kv>
{ownLitter && (
<>
<Kv label={t.detail.parentLittersRoleVater}>{parentLink(fatherId, father.data)}</Kv>
<Kv label={t.detail.parentLittersRoleMutter}>{parentLink(motherId, mother.data)}</Kv>
</>
)}
<Kv label={t.fields.origin}>
{g.originContactId && contactName.has(g.originContactId) ? (
<Link to={`/kontakte/${g.originContactId}`}>{contactName.get(g.originContactId)}</Link>
) : (
g.originBreeder || '—'
)}
</Kv>
{g.status !== 'Deceased' && (
<Kv label={t.fields.receiver}>{lookup(contactName, g.receiverContactId)}</Kv>
)}
<Kv label={t.fields.notes}>{g.notes || '—'}</Kv>
</dl>
</section>
<section className="ak-card">
<h2 className="ak-h2">{t.detail.genetics}</h2>
{geno ? (
<dl className="ak-kvlist">
<Kv label={t.fields.genotype}>
<code className="ak-genotype">{geno.display}</code>
</Kv>
<Kv label={t.detail.resolvedPrefix}>
{geno.farbschlag}
{storedColorName &&
geno.farbschlag !== de.genetics.unknownFarbschlag &&
storedColorName !== geno.farbschlag.replace(' Schecke', '').replace(' Rex', '') && (
<small className="mismatch-hint"> {t.detail.farbschlagMismatch}</small>
<small className="ak-mismatch"> {t.detail.farbschlagMismatch}</small>
)}
</>
}
/>
</dl>
) : (
<p className="muted">{t.detail.genotypeNotSet}</p>
)}
</Kv>
</dl>
) : (
<p className="ak-empty">{t.detail.genotypeNotSet}</p>
)}
</section>
<details
className="charakter-details"
open={isCharacterExpanded}
onToggle={(e) => setIsCharacterExpanded(e.currentTarget.open)}
>
<summary className="charakter-summary">
<h3>{de.character.sectionTitle}</h3>
<span className="charakter-chevron" aria-hidden="true"></span>
</summary>
<div className="charakter-body">
<Charakterbogen
traits={charTraits}
note={charNote}
onTraitsChange={setCharTraits}
onNoteChange={setCharNote}
<section className="ak-card">
<button
type="button"
className="ak-card-toggle"
aria-expanded={charExpanded}
onClick={() => setCharExpanded((v) => !v)}
>
<h2 className="ak-h2">{de.character.sectionTitle}</h2>
<span className="ak-card-chev" aria-hidden="true">
{charExpanded ? '▴' : '▾'}
</span>
</button>
{charExpanded && (
<>
{TRAIT_CATEGORIES.map((cat) => {
const count = cat.traits.filter((tr) => selectedTraits.has(tr.key)).length
return (
<div className="ak-cgroup" key={cat.category}>
<div className="ak-glabel">
{cat.category}
{count > 0 && <span className="ak-gcount">{count}</span>}
</div>
<div className="ak-chips">
{cat.traits.map((tr) => {
const on = selectedTraits.has(tr.key)
return (
<button
key={tr.key}
type="button"
className={'ak-chip' + (on ? ' on' : '') + (tr.warn ? ' warn' : '')}
aria-pressed={on}
onClick={() => toggleTrait(tr.key)}
>
{tr.warn && <span aria-hidden="true"></span>}
{tr.label}
</button>
)
})}
</div>
</div>
)
})}
<textarea
className="ak-notes"
value={charNote}
placeholder={de.character.notePlaceholder}
onChange={(e) => setCharNote(e.target.value)}
/>
<div className="form-actions" style={{ marginTop: '1rem' }}>
<div className="ak-saverow">
<button
type="button"
className="btn btn--primary"
className="ak-btn primary"
disabled={saveCharacter.pending}
onClick={async () => {
const r = await saveCharacter.run()
if (r.ok) gerbil.reload()
if (r.ok) {
toast.success(de.common.saved)
gerbil.reload()
} else {
toast.error(r.error)
}
}}
>
{saveCharacter.pending ? t.form.saving : de.character.save}
</button>
{saveCharacter.error && <span className="error-text">{saveCharacter.error}</span>}
</div>
</>
)}
</section>
<section className="ak-card">
<h2 className="ak-h2">{t.detail.parentLittersTitle}</h2>
{parentLitters.loading && <p className="muted">{de.common.loading}</p>}
{!parentLitters.loading && (parentLitters.data?.items.length ?? 0) === 0 && (
<p className="ak-empty">{t.detail.parentLittersEmpty}</p>
)}
{(parentLitters.data?.items.length ?? 0) > 0 && (
<ul className="card-list">
{(parentLitters.data?.items ?? []).map((litter) => (
<li key={litter.id}>
<Link to={`/wuerfe/${litter.id}`} className="gerbil-card">
<span className="gerbil-card__name">{litter.name}</span>
<span className="badge badge--active">
{litter.fatherId === id
? t.detail.parentLittersRoleVater
: t.detail.parentLittersRoleMutter}
</span>
<span className="gerbil-card__meta">{formatDate(litter.date)}</span>
{litter.totalBorn !== null && (
<span className="gerbil-card__meta">
{litter.totalBorn} {de.pages.litters.countLabel}
</span>
)}
</Link>
</li>
))}
</ul>
)}
</section>
<section className="ak-card">
<h2 className="visually-hidden">{t.detail.moreData}</h2>
<div className="ak-tabs" role="tablist">
{(['photos', 'health', 'weight'] as const).map((key) => (
<button
key={key}
type="button"
role="tab"
aria-selected={tab === key}
className={tab === key ? 'ak-tab active' : 'ak-tab'}
onClick={() => setTab(key)}
>
{t.detail.tabs[key]}
</button>
))}
</div>
</div>
</details>
<h3>{t.detail.parentLittersTitle}</h3>
{parentLitters.loading && <p className="muted">{de.common.loading}</p>}
{!parentLitters.loading && (parentLitters.data?.items.length ?? 0) === 0 && (
<p className="muted">{t.detail.parentLittersEmpty}</p>
)}
{(parentLitters.data?.items.length ?? 0) > 0 && (
<ul className="card-list">
{(parentLitters.data?.items ?? []).map((litter) => (
<li key={litter.id}>
<Link to={`/wuerfe/${litter.id}`} className="gerbil-card">
<span className="gerbil-card__name">{litter.name}</span>
<span className="badge badge--active">
{litter.fatherId === id ? t.detail.parentLittersRoleVater : t.detail.parentLittersRoleMutter}
</span>
<span className="gerbil-card__meta">{formatDate(litter.date)}</span>
{litter.totalBorn !== null && (
<span className="gerbil-card__meta">
{litter.totalBorn} {de.pages.litters.countLabel}
</span>
)}
</Link>
</li>
))}
</ul>
)}
<h3 className="visually-hidden">{t.detail.moreData}</h3>
<div className="tabs" role="tablist">
{(['photos', 'health', 'weight'] as const).map((key) => (
<button
key={key}
type="button"
role="tab"
aria-selected={tab === key}
className={tab === key ? 'tab tab--active' : 'tab'}
onClick={() => setTab(key)}
>
{t.detail.tabs[key]}
</button>
))}
</div>
<div className="tab-panel" role="tabpanel">
{tab === 'photos' && <GerbilPhotosTab gerbilId={g.id} />}
{tab === 'health' && <GerbilHealthTab gerbilId={g.id} />}
{tab === 'weight' && <GerbilWeightTab gerbilId={g.id} />}
<div className="ak-tabpanel" role="tabpanel">
{tab === 'photos' && <GerbilPhotosTab gerbilId={g.id} />}
{tab === 'health' && <GerbilHealthTab gerbilId={g.id} />}
{tab === 'weight' && <GerbilWeightTab gerbilId={g.id} />}
</div>
</section>
</div>
</section>
)

View File

@@ -3,12 +3,14 @@ import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { de } from '../strings/de'
import { createGerbil, getGerbil, updateGerbil } from '../api/gerbils'
import { listColorVarieties, listContacts, listEnclosures, listLitters } from '../api/lookups'
import { getBreederProfile } from '../api/settings'
import { SELECTABLE_STATUSES, type CreateGerbil, type Gender, type GerbilStatus } from '../api/types'
import { useApi, useMutation } from '../hooks/useApi'
import { genderLabel, statusLabel } from '../format/labels'
import { fromDisplayString, genotypeToFarbschlag, UNKNOWN_FARBSCHLAG } from '../genetics'
import NameSuggestPanel from '../components/NameSuggestPanel'
import ColorVarietyPicker from '../components/ColorVarietyPicker'
import { useToast } from '../components/toast'
import '../components/NameSuggestPanel.css'
interface FormState {
@@ -120,6 +122,7 @@ function isGenotypeValid(genotype: string): boolean {
export default function GerbilFormPage() {
const t = de.pages.gerbils
const navigate = useNavigate()
const toast = useToast()
const { id } = useParams()
const isEdit = Boolean(id)
@@ -133,6 +136,9 @@ export default function GerbilFormPage() {
const enclosures = useApi(() => listEnclosures(), [])
const contacts = useApi(() => listContacts(), [])
const litters = useApi(() => listLitters(), [])
// Own Zucht is the default Herkunft (empty origin = own-bred); label it with the
// breeder profile's Zuchtname so new gerbils default to the own cattery.
const breederProfile = useApi(() => getBreederProfile(), [])
// Prefill from the loaded gerbil once (edit mode). React's "adjust state
// during render" pattern — runs without an effect and re-renders immediately.
@@ -203,7 +209,12 @@ export default function GerbilFormPage() {
isCastrated: form.isCastrated,
}
const result = await mutation.run(body)
if (result.ok) navigate(`/rennmaeuse/${result.value.id}`)
if (result.ok) {
toast.success(de.common.saved)
navigate(`/rennmaeuse/${result.value.id}`)
} else {
toast.error(result.error)
}
// On failure mutation.error drives the inline alert; run() never throws.
}
@@ -245,7 +256,11 @@ export default function GerbilFormPage() {
<label className="field">
<span>{t.fields.origin}</span>
<select value={form.originContactId} onChange={(e) => set('originContactId', e.target.value)}>
<option value="">{t.form.none}</option>
<option value="">
{breederProfile.data?.zuchtName?.trim()
? `${t.form.ownBreeding}${breederProfile.data.zuchtName.trim()}`
: t.form.ownBreeding}
</option>
{(contacts.data ?? []).filter((c) => c.isBreeder || c.id === form.originContactId).map((c) => (
<option key={c.id} value={c.id}>
{c.name}
@@ -473,17 +488,20 @@ export default function GerbilFormPage() {
</label>
)}
<label className="field">
<span>{t.fields.enclosure}</span>
<select value={form.enclosureId} onChange={(e) => set('enclosureId', e.target.value)}>
<option value="">{t.form.none}</option>
{(enclosures.data ?? []).map((en) => (
<option key={en.id} value={en.id}>
{en.name}
</option>
))}
</select>
</label>
{/* Abgegebene/verstorbene Tiere sind nicht mehr im eigenen Bestand → kein Gehege. */}
{form.status !== 'GivenAway' && form.status !== 'Deceased' && (
<label className="field">
<span>{t.fields.enclosure}</span>
<select value={form.enclosureId} onChange={(e) => set('enclosureId', e.target.value)}>
<option value="">{t.form.none}</option>
{(enclosures.data ?? []).map((en) => (
<option key={en.id} value={en.id}>
{en.name}
</option>
))}
</select>
</label>
)}
<label className="field">
<span>{t.fields.litter}</span>

View File

@@ -5,6 +5,7 @@ import { Link, useNavigate, useParams } from 'react-router-dom'
import { de } from '../strings/de'
import { createContact, getContact, updateContact, type CreateContact } from '../api/contacts'
import { useApi, useMutation } from '../hooks/useApi'
import { useToast } from '../components/toast'
interface FormState {
name: string
@@ -14,9 +15,19 @@ interface FormState {
notes: string
isBreeder: boolean
isReceiver: boolean
nameSuffix: string
}
const EMPTY: FormState = { name: '', email: '', phone: '', address: '', notes: '', isBreeder: false, isReceiver: true }
const EMPTY: FormState = {
name: '',
email: '',
phone: '',
address: '',
notes: '',
isBreeder: false,
isReceiver: true,
nameSuffix: '',
}
/** "" -> null, sonst der Wert. */
const nn = (s: string): string | null => (s.trim() === '' ? null : s)
@@ -24,6 +35,7 @@ const nn = (s: string): string | null => (s.trim() === '' ? null : s)
export default function KontaktFormPage() {
const t = de.pages.kontakte
const navigate = useNavigate()
const toast = useToast()
const { id } = useParams()
const isEdit = Boolean(id)
@@ -44,6 +56,7 @@ export default function KontaktFormPage() {
notes: existing.data.notes ?? '',
isBreeder: existing.data.isBreeder,
isReceiver: existing.data.isReceiver,
nameSuffix: existing.data.nameSuffix ?? '',
})
}
@@ -69,9 +82,15 @@ export default function KontaktFormPage() {
notes: nn(form.notes),
isBreeder: form.isBreeder,
isReceiver: form.isReceiver,
nameSuffix: form.isBreeder ? nn(form.nameSuffix) : null,
})
if (result.ok) navigate(`/kontakte/${result.value.id}`)
// On failure mutation.error drives the inline alert; run() never throws.
if (result.ok) {
toast.success(de.common.saved)
navigate(`/kontakte/${result.value.id}`)
} else {
toast.error(result.error)
}
// On failure mutation.error also drives the inline alert; run() never throws.
}
if (isEdit && existing.loading) return <p className="muted">{de.common.loading}</p>
@@ -140,6 +159,18 @@ export default function KontaktFormPage() {
/>
</label>
{form.isBreeder && (
<label className="field">
<span>{t.fields.nameSuffix}</span>
<input
className="input"
value={form.nameSuffix}
onChange={(e) => set('nameSuffix', e.target.value)}
/>
<small className="muted">{t.fields.nameSuffixHint}</small>
</label>
)}
<label className="field">
<span>{t.fields.notes}</span>
<textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} />

View File

@@ -1,56 +1,126 @@
/** FEAT-2: Kontakte-Liste — Gridify-Namenssuche + Infinite Scroll, Link auf Detailseite. */
import { useState } from 'react'
/** FEAT-2: Kontakte-Liste — Quick-Chip-Filter (Rolle), Sticky-Header + Infinite Scroll. */
import { useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { de } from '../strings/de'
import { listContactsPaged } from '../api/contacts'
import { condition } from '../api/gridify'
import { andFilter, condition } from '../api/gridify'
import { useInfiniteList, useInfiniteSentinel } from '../hooks/useInfiniteList'
import {
GerbilFilterBar,
ActiveFilterChips,
type FilterCategory,
} from '../components/GerbilFilterBar'
// Reuse the Rennmäuse list's sticky header + card styles for a consistent look.
import './gerbils.css'
const PAGE_SIZE = 20
export default function KontaktePage() {
const t = de.pages.kontakte
const [search, setSearch] = useState('')
// Role filter: 'breeder' (Züchter) / 'receiver' (Abnehmer); multi-select.
const [roles, setRoles] = useState<string[]>([])
// Sticky header: collapse the title block once scrolled (IntersectionObserver sentinel).
const [condensed, setCondensed] = useState(false)
const topSentinelRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const el = topSentinelRef.current
if (!el) return
const io = new IntersectionObserver(([entry]) => setCondensed(!entry.isIntersecting))
io.observe(el)
return () => io.disconnect()
}, [])
const categories: FilterCategory[] = [
{
key: 'rolle',
label: t.filters.role,
options: [
{ value: 'breeder', label: t.filters.breeder },
{ value: 'receiver', label: t.filters.receiver },
],
},
]
const selected = { rolle: roles }
const toggleRole = (_key: string, value: string) =>
setRoles((r) => (r.includes(value) ? r.filter((v) => v !== value) : [...r, value]))
const removeRole = (_key: string, value: string) => setRoles((r) => r.filter((v) => v !== value))
const clearAll = () => setRoles([])
// Role conditions map to different fields, so they're OR'd into one group here
// (rather than via orGroup which assumes a single field).
const roleConds = [
roles.includes('breeder') && condition({ field: 'isBreeder', op: '==', value: true }),
roles.includes('receiver') && condition({ field: 'isReceiver', op: '==', value: true }),
].filter((c): c is string => Boolean(c))
const roleFilter =
roleConds.length === 0 ? '' : roleConds.length === 1 ? roleConds[0] : `(${roleConds.join('|')})`
const filter = andFilter(
search.trim() && condition({ field: 'name', op: 'contains', value: search.trim() }),
roleFilter,
)
const filter = search.trim()
? condition({ field: 'name', op: 'contains', value: search.trim() })
: undefined
// Infinite scroll: one page at a time, accumulated. The trailing `id` makes the
// ordering a total order so OFFSET pagination never repeats/omits a row.
const list = useInfiniteList(
(page) => listContactsPaged({ filter, orderBy: 'name,id', page, pageSize: PAGE_SIZE }),
`${filter ?? ''}`,
(page) =>
listContactsPaged({ filter: filter || undefined, orderBy: 'name,id', page, pageSize: PAGE_SIZE }),
filter,
)
const sentinelRef = useInfiniteSentinel(list)
const { items, total, hasMore, loading, loadingMore } = list
return (
<section className="page">
<header className="page-head">
<div>
<h2>{t.title}</h2>
{!loading && (
<p className="muted">
{total} {t.countLabel}
</p>
<section className="page gerbils-page">
<div ref={topSentinelRef} className="gerbils-scrolltop" aria-hidden="true" />
<div className="gerbils-topbar">
<header className={`page-head gerbils-head${condensed ? ' gerbils-head--condensed' : ''}`}>
<div>
<h2>{t.title}</h2>
{!loading && (
<p className="muted">
{total} {t.countLabel}
</p>
)}
</div>
<Link to="/kontakte/neu" className="btn btn--primary">
+ {t.newButton}
</Link>
</header>
<div className="rmf-searchbar">
<span className="rmf-search-ico" aria-hidden="true">
</span>
<input
type="search"
className="rmf-searchfield"
placeholder={t.searchPlaceholder}
value={search}
onChange={(e) => setSearch(e.target.value)}
aria-label={t.fields.name}
/>
{search && (
<button
type="button"
className="rmf-search-clear"
onClick={() => setSearch('')}
aria-label={de.filterBar.searchClear}
>
×
</button>
)}
</div>
<Link to="/kontakte/neu" className="btn btn--primary">
+ {t.newButton}
</Link>
</header>
<div className="filters">
<input
type="search"
className="input"
placeholder={t.searchPlaceholder}
value={search}
onChange={(e) => setSearch(e.target.value)}
aria-label={t.fields.name}
/>
<GerbilFilterBar categories={categories} selected={selected} onToggle={toggleRole} />
</div>
<ActiveFilterChips
categories={categories}
selected={selected}
onRemove={removeRole}
onClearAll={clearAll}
/>
{loading && <p className="muted">{de.common.loading}</p>}
{list.error && (
<div className="alert alert--error">
@@ -66,7 +136,7 @@ export default function KontaktePage() {
{items.length > 0 && (
<ul className="card-list">
{items.map((c) => (
<li key={c.id}>
<li key={c.id} className="gerbil-row">
<Link to={`/kontakte/${c.id}`} className="gerbil-card">
<span className="gerbil-card__name">
{c.name}
@@ -82,7 +152,7 @@ export default function KontaktePage() {
fontWeight: 'normal',
}}
>
Züchter
{t.filters.breeder}
</span>
)}
{c.isReceiver && (
@@ -97,7 +167,7 @@ export default function KontaktePage() {
fontWeight: 'normal',
}}
>
Abnehmer
{t.filters.receiver}
</span>
)}
</span>

View File

@@ -4,11 +4,12 @@ import { de } from '../strings/de'
import { listLitters } from '../api/litters'
import { listGerbils } from '../api/gerbils'
import { andFilter, condition, escapeGridifyValue } from '../api/gridify'
import type { Litter } from '../api/types'
import type { Gerbil, Litter } from '../api/types'
import { useApi } from '../hooks/useApi'
import { useInfiniteList, useInfiniteSentinel } from '../hooks/useInfiniteList'
import { formatDate } from '../format/labels'
import { FilterPanel } from '../components/FilterPanel'
import { useGerbilName } from '../components/breederSuffix'
const PAGE_SIZE = 20
@@ -53,14 +54,19 @@ export default function WuerfeListPage() {
const [year, setYear] = useState('')
const [sort, setSort] = useState<SortKey>('dateDesc')
// Parent names (shared by both tabs).
// Parent names (shared by both tabs) — keep the full gerbil so the Zucht suffix applies.
const gerbilName = useGerbilName()
const gerbils = useApi(() => listGerbils({ page: 1, pageSize: 1000, orderBy: 'name' }), [])
const nameById = useMemo(() => {
const map = new Map<string, string>()
for (const g of gerbils.data?.items ?? []) map.set(g.id, g.name)
const gerbilById = useMemo(() => {
const map = new Map<string, Gerbil>()
for (const g of gerbils.data?.items ?? []) map.set(g.id, g)
return map
}, [gerbils.data])
const parentName = (id: string | null) => (id ? (nameById.get(id) ?? t.detail.unknownParent) : '—')
const parentName = (id: string | null): string => {
if (!id) return '—'
const gp = gerbilById.get(id)
return gp ? gerbilName(gp) || t.detail.unknownParent : t.detail.unknownParent
}
// Litters tab (paged, filtered).
const filter = andFilter(
@@ -90,7 +96,7 @@ export default function WuerfeListPage() {
parentName(p.fatherId).toLowerCase().includes(term) ||
parentName(p.motherId).toLowerCase().includes(term)
)
}, [pairs, search, nameById])
}, [pairs, search, gerbilById])
const items = litters.items

View File

@@ -13,6 +13,7 @@ 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 { useGerbilName } from '../components/breederSuffix'
import './wuerfe.css'
type JuvenileFields = (typeof de.pages.litters.detail.juvenileFields)
@@ -23,6 +24,7 @@ function JuvenileCard({ g, farbe, receiver, tJf }: {
receiver: string | null
tJf: JuvenileFields
}) {
const gerbilName = useGerbilName()
const [failedUrl, setFailedUrl] = useState<string | null>(null)
const rawUrl = g.profilePhotoUrl
? /^(https?:|data:|\/\/)/.test(g.profilePhotoUrl)
@@ -46,7 +48,7 @@ function JuvenileCard({ g, farbe, receiver, tJf }: {
)}
</div>
<div className="juvenile-body">
<span className="gerbil-card__name">{g.name}</span>
<span className="gerbil-card__name">{gerbilName(g)}</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>}
@@ -60,6 +62,7 @@ function JuvenileCard({ g, farbe, receiver, tJf }: {
export default function WurfDetailPage() {
const t = de.pages.litters
const gerbilName = useGerbilName()
const { id = '' } = useParams()
const litter = useApi(() => getLitter(id), [id])
@@ -137,8 +140,8 @@ export default function WurfDetailPage() {
const l = litter.data
const registered = juveniles.data?.totalCount ?? 0
const parentLink = (id: string | null, g: { name: string } | null) =>
id && g ? <Link to={`/rennmaeuse/${id}`}>{g.name}</Link> : t.detail.unknownParent
const parentLink = (id: string | null, g: Gerbil | null) =>
id && g ? <Link to={`/rennmaeuse/${id}`}>{gerbilName(g)}</Link> : t.detail.unknownParent
return (
<section className="page">

View File

@@ -6,6 +6,7 @@ import { createLitter, getLitter, updateLitter } from '../api/litters'
import { listGerbils } from '../api/gerbils'
import type { CreateLitter } from '../api/types'
import { useApi, useMutation } from '../hooks/useApi'
import { useToast } from '../components/toast'
import AnimalPicker from '../components/AnimalPicker'
interface FormState {
@@ -47,6 +48,7 @@ const nn = (s: string): string | null => (s.trim() === '' ? null : s)
export default function WurfFormPage() {
const t = de.pages.litters
const navigate = useNavigate()
const toast = useToast()
const { id } = useParams()
const isEdit = Boolean(id)
@@ -126,6 +128,7 @@ export default function WurfFormPage() {
setSubmitError(null)
const result = await mutation.run(body)
if (result.ok) {
toast.success(de.common.saved)
navigate(`/wuerfe/${result.value.id}`)
return
}

View File

@@ -0,0 +1,415 @@
/* DESIGN (Rennmausakte.html): gerbil detail page — hero photo + card sections.
Navigation is handled by the app shell (sidebar/bottom bar), so the design's own
sidebar/drawer is intentionally dropped. Palette uses the app's design tokens. */
.ak {
--ak-panel: #efe6d4;
--ak-tan: #ece0c8;
--ak-warn: #c2702a;
--ak-danger: #a6433a;
--ak-kv-pad: 14px;
/* Cancel app-main's horizontal padding so the hero spans full width. app-main has
no top padding (see index.css), so the hero already sits flush at the top. */
margin: 0 -1rem;
}
@media (min-width: 768px) {
.ak {
margin: 0 -2rem;
}
}
/* ---------- Hero ---------- */
.ak-hero {
position: relative;
}
.ak-herophoto {
width: 100%;
height: 250px;
display: block;
object-fit: cover;
background: var(--ak-panel);
}
.ak-herophoto--empty {
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-faint);
font-size: 2.5rem;
}
.ak-herobar {
position: absolute;
top: 0;
left: 0;
right: 0;
display: flex;
justify-content: flex-start;
padding: 14px 16px;
}
.ak-back {
color: #fff;
background: rgba(20, 12, 6, 0.42);
width: 36px;
height: 36px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 20px;
cursor: pointer;
border: none;
text-decoration: none;
}
.ak-heroover {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 40px 18px 16px;
background: linear-gradient(to top, rgba(20, 12, 6, 0.82), rgba(20, 12, 6, 0.1) 72%, transparent);
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.ak-name {
margin: 0;
font-size: 30px;
font-weight: 800;
letter-spacing: -0.02em;
color: #fff;
}
.ak-statusbadge {
font-size: 13px;
font-weight: 700;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.94);
white-space: nowrap;
}
.ak-badge-extern {
font-size: 12px;
font-weight: 700;
padding: 4px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.82);
color: #6b5d4b;
border: 1px dashed rgba(107, 93, 75, 0.5);
}
.ak-content {
max-width: 940px;
margin: 0 auto;
padding: 16px 16px 40px;
}
@media (min-width: 768px) {
.ak-herophoto {
height: 320px;
}
.ak-heroover {
padding: 56px 36px 22px;
}
.ak-name {
font-size: 40px;
}
.ak-content {
padding: 24px 36px 40px;
}
/* On desktop the app sidebar provides navigation; the hero back arrow is redundant
(the action row has "Zurück zur Liste"). */
.ak-herobar {
display: none;
}
}
/* ---------- Quick facts ---------- */
.ak-facts {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.ak-fact {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 999px;
padding: 8px 13px;
font-size: 14px;
font-weight: 600;
color: var(--color-text);
}
/* ---------- Actions ---------- */
.ak-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 16px 0 4px;
}
.ak-btn {
font: inherit;
font-size: 15px;
font-weight: 700;
cursor: pointer;
padding: 11px 18px;
border-radius: 14px;
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
white-space: nowrap;
box-shadow: 0 1px 2px rgba(61, 46, 35, 0.05);
transition: transform 0.1s, filter 0.15s, background 0.15s;
}
.ak-btn:active {
transform: scale(0.97);
}
.ak-btn:hover {
background: var(--color-accent-soft);
}
.ak-btn.primary {
background: var(--color-accent);
color: #fff;
border-color: transparent;
}
.ak-btn.primary:hover {
filter: brightness(1.06);
background: var(--color-accent);
}
.ak-btn:disabled {
opacity: 0.6;
cursor: default;
}
/* ---------- Cards ---------- */
.ak-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 18px;
padding: 18px;
margin-top: 14px;
box-shadow: 0 1px 3px rgba(61, 46, 35, 0.06);
}
.ak-h2 {
font-size: 20px;
font-weight: 800;
letter-spacing: -0.01em;
margin: 0 0 8px;
color: var(--color-text);
}
/* Collapsible card header (e.g. Charakter) — full-width toggle button. */
.ak-card-toggle {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
background: none;
border: none;
padding: 0;
cursor: pointer;
font: inherit;
color: inherit;
text-align: left;
}
.ak-card-toggle .ak-h2 {
margin: 0;
}
.ak-card-chev {
font-size: 14px;
color: var(--color-text-muted);
flex-shrink: 0;
}
/* ---------- Key/Value list (semantic dl/dt/dd, styled as a grid) ---------- */
.ak-kvlist {
display: flex;
flex-direction: column;
margin: 0;
}
.ak-kvlist dt,
.ak-kvlist dd {
margin: 0;
}
.ak-kv {
display: grid;
grid-template-columns: minmax(96px, 32%) 1fr;
gap: 14px;
align-items: center;
padding: var(--ak-kv-pad) 2px;
border-bottom: 1px solid var(--color-border);
}
.ak-kv:last-child {
border-bottom: none;
}
.ak-kv-label {
color: var(--color-text-muted);
font-weight: 500;
font-size: 15px;
}
.ak-kv-value {
color: var(--color-text);
font-weight: 600;
font-size: 16px;
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.ak-kv-value a {
color: var(--color-accent);
font-weight: 700;
text-decoration: underline;
text-underline-offset: 3px;
}
.ak-genotype {
font-family: ui-monospace, Menlo, Consolas, monospace;
background: var(--ak-tan);
color: var(--color-text);
padding: 6px 11px;
border-radius: 8px;
font-size: 15px;
letter-spacing: 0.02em;
}
.ak-mismatch {
color: var(--ak-warn);
font-weight: 600;
}
/* ---------- Character chips ---------- */
.ak-cgroup {
margin-bottom: 16px;
}
.ak-cgroup:last-of-type {
margin-bottom: 0;
}
.ak-glabel {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-text-muted);
margin-bottom: 10px;
}
.ak-gcount {
background: var(--color-accent);
color: #fff;
font-size: 11px;
font-weight: 800;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
display: inline-flex;
align-items: center;
justify-content: center;
letter-spacing: 0;
}
.ak-chips {
display: flex;
flex-wrap: wrap;
gap: 7px;
}
.ak-chip {
display: inline-flex;
align-items: center;
gap: 6px;
font: inherit;
font-size: 13px;
font-weight: 600;
cursor: pointer;
padding: 8px 12px;
border-radius: 999px;
background: var(--color-surface);
color: var(--color-text);
border: 1.5px solid var(--color-border);
transition: background 0.14s, border-color 0.14s, color 0.14s, transform 0.14s
cubic-bezier(0.22, 1, 0.36, 1);
}
.ak-chip:active {
transform: scale(0.95);
}
.ak-chip.on {
background: var(--color-accent);
border-color: var(--color-accent);
color: #fff;
}
.ak-chip.warn {
border-color: color-mix(in srgb, var(--ak-warn) 45%, var(--color-border));
color: var(--ak-warn);
}
.ak-chip.warn.on {
background: var(--ak-warn);
border-color: var(--ak-warn);
color: #fff;
}
.ak-notes {
width: 100%;
min-height: 78px;
resize: vertical;
font: inherit;
font-size: 15px;
color: var(--color-text);
margin-top: 14px;
background: #fbf7ef;
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 12px 14px;
outline: none;
}
.ak-notes:focus {
border-color: var(--color-accent);
}
.ak-empty {
color: var(--color-text-muted);
font-size: 16px;
margin: 2px 0 0;
}
/* ---------- Tabs ---------- */
.ak-tabs {
display: flex;
gap: 26px;
border-bottom: 1px solid var(--color-border);
overflow-x: auto;
scrollbar-width: none;
}
.ak-tabs::-webkit-scrollbar {
display: none;
}
.ak-tab {
background: none;
border: none;
font: inherit;
cursor: pointer;
padding: 6px 2px 12px;
font-size: 16px;
font-weight: 700;
color: var(--color-text-muted);
border-bottom: 2px solid transparent;
margin-bottom: -1px;
white-space: nowrap;
}
.ak-tab.active {
color: var(--color-accent);
border-bottom-color: var(--color-accent);
}
.ak-tabpanel {
padding-top: 18px;
}
/* Character save row */
.ak-saverow {
display: flex;
align-items: center;
gap: 1rem;
margin-top: 14px;
}