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>
293 lines
10 KiB
TypeScript
293 lines
10 KiB
TypeScript
import { useMemo, useState } from 'react'
|
||
import { useNavigate } from 'react-router-dom'
|
||
import JSZip from 'jszip'
|
||
import { de } from '../strings/de'
|
||
import type { Gerbil } from '../api/types'
|
||
import { listGerbilPhotos, photoSrc, type GerbilPhoto } from '../api/photos'
|
||
import { generateSaleAd } from '../api/saleAd'
|
||
import { useApi, useMutation } from '../hooks/useApi'
|
||
import { formatDate, genderLabel } from '../format/labels'
|
||
import { traitLabels } from '../format/traits'
|
||
import FarbschlagImage from './FarbschlagImage'
|
||
import Charakterbogen from './Charakterbogen'
|
||
import { useGerbilName } from './breederSuffix'
|
||
|
||
type SaleStatus = 'free' | 'loose' | 'reserved'
|
||
|
||
export interface GroupComposerProps {
|
||
/** 1-based group number for the heading. */
|
||
groupNumber: number
|
||
animals: Gerbil[]
|
||
/** Resolve a gerbil's Farbschlag display name (stored ColorVariety or '—'). */
|
||
farbschlagOf: (g: Gerbil) => string
|
||
}
|
||
|
||
/** Group gender label for the heading ("männlich"/"weiblich"/"gemischt"). */
|
||
function groupGender(animals: Gerbil[]): string {
|
||
const g = de.pages.abgabe.listing
|
||
const genders = new Set(animals.map((a) => a.gender))
|
||
if (genders.size === 1 && genders.has('male')) return g.genderMale
|
||
if (genders.size === 1 && genders.has('female')) return g.genderFemale
|
||
return g.genderMixed
|
||
}
|
||
|
||
export default function GroupComposer({ groupNumber, animals, farbschlagOf }: GroupComposerProps) {
|
||
const t = de.pages.abgabe
|
||
const gerbilName = useGerbilName()
|
||
const navigate = useNavigate()
|
||
const [status, setStatus] = useState<SaleStatus>('free')
|
||
const [reservedName, setReservedName] = useState('')
|
||
const [tagline, setTagline] = useState('')
|
||
// Per-animal character note, seeded from the persisted characterNote (or notes).
|
||
const [personality, setPersonality] = useState<Record<string, string>>(() =>
|
||
Object.fromEntries(animals.map((a) => [a.id, a.characterNote ?? a.notes ?? ''])),
|
||
)
|
||
// Per-animal selected trait keys, seeded from the persisted Charakterbogen.
|
||
const [traitsByAnimal, setTraitsByAnimal] = useState<Record<string, string[]>>(() =>
|
||
Object.fromEntries(animals.map((a) => [a.id, a.characterTraits ?? []])),
|
||
)
|
||
const [selectedPhotos, setSelectedPhotos] = useState<Record<string, boolean>>({})
|
||
const [hints, setHints] = useState('')
|
||
const [notice, setNotice] = useState<string | null>(null)
|
||
const [aiText, setAiText] = useState<string | null>(null)
|
||
const [zipping, setZipping] = useState(false)
|
||
|
||
const animalIds = animals.map((a) => a.id).join(',')
|
||
// Load photos per animal; tolerate a missing photo endpoint (-> empty).
|
||
const photoData = useApi(
|
||
() =>
|
||
Promise.all(
|
||
animals.map((a) =>
|
||
listGerbilPhotos(a.id)
|
||
.then((photos) => ({ id: a.id, photos }))
|
||
.catch(() => ({ id: a.id, photos: [] as GerbilPhoto[] })),
|
||
),
|
||
),
|
||
[animalIds],
|
||
)
|
||
const photosById = useMemo(() => {
|
||
const m = new Map<string, GerbilPhoto[]>()
|
||
for (const e of photoData.data ?? []) m.set(e.id, e.photos)
|
||
return m
|
||
}, [photoData.data])
|
||
|
||
const ai = useMutation(generateSaleAd)
|
||
|
||
const statusLine = (() => {
|
||
if (status === 'loose') return `${t.listing.statusLooseReserved} ${reservedName}`.trim()
|
||
if (status === 'reserved') return t.listing.statusReserved
|
||
return t.listing.statusFree
|
||
})()
|
||
|
||
const listingText = useMemo(() => {
|
||
const heading = t.listing.headingFor(groupNumber, groupGender(animals))
|
||
const lines: string[] = [heading, `${t.listing.status}: ${statusLine}`, '']
|
||
if (tagline.trim()) lines.push(tagline.trim(), '')
|
||
for (const a of animals) {
|
||
const fs = farbschlagOf(a)
|
||
const born = a.dateOfBirth ? `, ${t.listing.bornOn} ${formatDate(a.dateOfBirth)}` : ''
|
||
lines.push(`${a.name} – ${fs}${born}`)
|
||
const p = (personality[a.id] ?? '').trim()
|
||
if (p) lines.push(p)
|
||
lines.push('')
|
||
}
|
||
return lines.join('\n').trimEnd()
|
||
}, [groupNumber, animals, statusLine, tagline, personality, farbschlagOf, t])
|
||
|
||
function togglePhoto(photoId: string) {
|
||
setSelectedPhotos((s) => ({ ...s, [photoId]: !isSelected(photoId) }))
|
||
}
|
||
// Default: a photo is selected unless explicitly toggled off.
|
||
function isSelected(photoId: string): boolean {
|
||
return selectedPhotos[photoId] ?? true
|
||
}
|
||
|
||
async function copyText() {
|
||
setNotice(null)
|
||
try {
|
||
await navigator.clipboard.writeText(aiText ?? listingText)
|
||
setNotice(t.export.copied)
|
||
} catch {
|
||
setNotice(t.export.copyFailed)
|
||
}
|
||
}
|
||
|
||
async function downloadPhotos() {
|
||
setNotice(null)
|
||
const zip = new JSZip()
|
||
const folder = zip.folder(`gruppe-${groupNumber}`)!
|
||
let count = 0
|
||
for (const a of animals) {
|
||
const photos = (photosById.get(a.id) ?? []).filter((p) => isSelected(p.id))
|
||
let n = 1
|
||
for (const p of photos) {
|
||
try {
|
||
const blob = await fetch(photoSrc(p)).then((r) => (r.ok ? r.blob() : null))
|
||
if (!blob) continue
|
||
const ext = (p.fileName.split('.').pop() ?? 'jpg').toLowerCase()
|
||
folder.file(`${slug(a.name)}-${n}.${ext}`, blob)
|
||
n += 1
|
||
count += 1
|
||
} catch {
|
||
/* skip unreachable photo */
|
||
}
|
||
}
|
||
}
|
||
if (count === 0) {
|
||
setNotice(t.export.noPhotosSelected)
|
||
return
|
||
}
|
||
try {
|
||
setZipping(true)
|
||
const out = await zip.generateAsync({ type: 'blob' })
|
||
triggerDownload(out, `gruppe-${groupNumber}-fotos.zip`)
|
||
} catch {
|
||
setNotice(t.export.zipFailed)
|
||
} finally {
|
||
setZipping(false)
|
||
}
|
||
}
|
||
|
||
async function improveWithAi() {
|
||
setNotice(null)
|
||
const result = await ai.run({
|
||
animals: animals.map((a) => ({
|
||
name: a.name,
|
||
farbschlag: farbschlagOf(a),
|
||
dateOfBirth: a.dateOfBirth,
|
||
notes: a.notes ?? null,
|
||
traits: traitLabels(traitsByAnimal[a.id] ?? []),
|
||
characterNote: personality[a.id] ?? null,
|
||
})),
|
||
statusLine,
|
||
hints,
|
||
})
|
||
if (result.ok) setAiText(result.value.text)
|
||
// On failure (endpoint not configured yet) the notice below shows the German hint.
|
||
}
|
||
|
||
return (
|
||
<section className="group-composer">
|
||
<h3>{t.listing.headingFor(groupNumber, groupGender(animals))}</h3>
|
||
|
||
<div className="group-composer__controls">
|
||
<label className="field">
|
||
<span>{t.listing.status}</span>
|
||
<select value={status} onChange={(e) => setStatus(e.target.value as SaleStatus)}>
|
||
<option value="free">{t.listing.statusFree}</option>
|
||
<option value="loose">{t.listing.statusLooseReserved}</option>
|
||
<option value="reserved">{t.listing.statusReserved}</option>
|
||
</select>
|
||
</label>
|
||
{status === 'loose' && (
|
||
<label className="field">
|
||
<span>{t.listing.reservedName}</span>
|
||
<input
|
||
className="input"
|
||
value={reservedName}
|
||
onChange={(e) => setReservedName(e.target.value)}
|
||
/>
|
||
</label>
|
||
)}
|
||
</div>
|
||
|
||
<label className="field">
|
||
<span>{t.listing.tagline}</span>
|
||
<input
|
||
className="input"
|
||
value={tagline}
|
||
placeholder={t.listing.taglinePlaceholder}
|
||
onChange={(e) => setTagline(e.target.value)}
|
||
/>
|
||
</label>
|
||
|
||
{animals.map((a) => (
|
||
<div key={a.id} className="group-composer__animal">
|
||
<div className="group-composer__animal-head">
|
||
<FarbschlagImage name={farbschlagOf(a)} size={36} />
|
||
<strong>{gerbilName(a) || a.name}</strong>
|
||
<span className="muted">
|
||
{farbschlagOf(a)} · {genderLabel(a.gender)}
|
||
{a.dateOfBirth ? ` · ${t.listing.bornOn} ${formatDate(a.dateOfBirth)}` : ''}
|
||
</span>
|
||
</div>
|
||
<Charakterbogen
|
||
traits={traitsByAnimal[a.id] ?? []}
|
||
note={personality[a.id] ?? ''}
|
||
onTraitsChange={(tr) => setTraitsByAnimal((s) => ({ ...s, [a.id]: tr }))}
|
||
onNoteChange={(v) => setPersonality((s) => ({ ...s, [a.id]: v }))}
|
||
/>
|
||
<div className="photo-select">
|
||
{(photosById.get(a.id) ?? []).length === 0 ? (
|
||
<small className="muted">{t.listing.noPhotos}</small>
|
||
) : (
|
||
(photosById.get(a.id) ?? []).map((p) => (
|
||
<button
|
||
key={p.id}
|
||
type="button"
|
||
className={isSelected(p.id) ? 'photo-chip photo-chip--on' : 'photo-chip'}
|
||
onClick={() => togglePhoto(p.id)}
|
||
aria-pressed={isSelected(p.id)}
|
||
>
|
||
<img src={photoSrc(p)} alt={p.caption ?? a.name} loading="lazy" />
|
||
</button>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
<label className="field">
|
||
<span>{t.ai.hintsLabel}</span>
|
||
<input className="input" value={hints} onChange={(e) => setHints(e.target.value)} />
|
||
</label>
|
||
|
||
<pre className="listing-preview">{aiText ?? listingText}</pre>
|
||
|
||
<div className="group-composer__actions">
|
||
<button type="button" className="btn btn--primary" onClick={copyText}>
|
||
{t.export.copyText}
|
||
</button>
|
||
<button type="button" className="btn" disabled={zipping} onClick={downloadPhotos}>
|
||
{zipping ? t.export.zipping : t.export.downloadPhotos}
|
||
</button>
|
||
<button type="button" className="btn" disabled={ai.pending} onClick={improveWithAi}>
|
||
{ai.pending ? t.ai.generating : t.ai.improve}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn"
|
||
onClick={() =>
|
||
navigate(`/vertraege/neu?tiere=${animals.map((a) => a.id).join(',')}`)
|
||
}
|
||
>
|
||
{t.export.finish}
|
||
</button>
|
||
</div>
|
||
|
||
{ai.error && <div className="alert alert--warning">{t.ai.notConfigured}</div>}
|
||
{notice && <div className="alert">{notice}</div>}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function slug(name: string): string {
|
||
return name
|
||
.toLowerCase()
|
||
.replace(/[äöü]/g, (c) => ({ ä: 'ae', ö: 'oe', ü: 'ue' })[c] ?? c)
|
||
.replace(/ß/g, 'ss')
|
||
.replace(/[^a-z0-9]+/g, '-')
|
||
.replace(/(^-|-$)/g, '')
|
||
}
|
||
|
||
function triggerDownload(blob: Blob, filename: string) {
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = filename
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
a.remove()
|
||
URL.revokeObjectURL(url)
|
||
}
|