FEAT-12a: Abgabe page — Becken-default grouping + per-group listing composer, clipboard/zip export, AI stub

This commit is contained in:
2026-06-06 07:09:48 +02:00
parent e4b7558abd
commit 44f306d18f
3 changed files with 488 additions and 3 deletions

View File

@@ -0,0 +1,276 @@
import { useMemo, useState } from 'react'
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 FarbschlagImage from './FarbschlagImage'
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 [status, setStatus] = useState<SaleStatus>('free')
const [reservedName, setReservedName] = useState('')
const [tagline, setTagline] = useState('')
// Per-animal personality text, seeded from notes on first render.
const [personality, setPersonality] = useState<Record<string, string>>(() =>
Object.fromEntries(animals.map((a) => [a.id, a.notes ?? ''])),
)
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: personality[a.id] ?? a.notes ?? 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>{a.name}</strong>
<span className="muted">
{farbschlagOf(a)} · {genderLabel(a.gender)}
{a.dateOfBirth ? ` · ${t.listing.bornOn} ${formatDate(a.dateOfBirth)}` : ''}
</span>
</div>
<label className="field">
<span>{t.listing.personality}</span>
<textarea
value={personality[a.id] ?? ''}
placeholder={t.listing.personalityPlaceholder}
onChange={(e) => setPersonality((s) => ({ ...s, [a.id]: e.target.value }))}
/>
</label>
<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" title={t.export.finishHint} disabled>
{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)
}