diff --git a/gerbil-manager-web/src/components/GroupComposer.tsx b/gerbil-manager-web/src/components/GroupComposer.tsx new file mode 100644 index 0000000..46eb221 --- /dev/null +++ b/gerbil-manager-web/src/components/GroupComposer.tsx @@ -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('free') + const [reservedName, setReservedName] = useState('') + const [tagline, setTagline] = useState('') + // Per-animal personality text, seeded from notes on first render. + const [personality, setPersonality] = useState>(() => + Object.fromEntries(animals.map((a) => [a.id, a.notes ?? ''])), + ) + const [selectedPhotos, setSelectedPhotos] = useState>({}) + const [hints, setHints] = useState('') + const [notice, setNotice] = useState(null) + const [aiText, setAiText] = useState(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() + 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 ( +
+

{t.listing.headingFor(groupNumber, groupGender(animals))}

+ +
+ + {status === 'loose' && ( + + )} +
+ + + + {animals.map((a) => ( +
+
+ + {a.name} + + {farbschlagOf(a)} · {genderLabel(a.gender)} + {a.dateOfBirth ? ` · ${t.listing.bornOn} ${formatDate(a.dateOfBirth)}` : ''} + +
+