Files
GerbilManager/gerbil-manager-web/src/components/GroupComposer.tsx

277 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}