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

119 lines
3.9 KiB
TypeScript

/**
* FEAT-6: Fotos-Tab — Galerie-Raster + Upload (multipart) + Löschen.
* Erstes Foto nach sortOrder = Profilfoto (Badge). Endpunkte entstehen in
* FEAT-1b Phase 2 (Pam) — bis dahin zeigt der Tab saubere deutsche
* Fehler-/Leerzustände.
*/
import { useRef, useState, type FormEvent } from 'react'
import { de } from '../strings/de'
import {
deleteGerbilPhoto,
listGerbilPhotos,
photoSrc,
profilePhoto,
uploadGerbilPhoto,
} from '../api/photos'
import { useApi, useMutation } from '../hooks/useApi'
export default function GerbilPhotosTab({ gerbilId }: { gerbilId: string }) {
const t = de.pages.tierTabs.photos
const photos = useApi(() => listGerbilPhotos(gerbilId), [gerbilId])
const fileInput = useRef<HTMLInputElement>(null)
const [caption, setCaption] = useState('')
const [fileError, setFileError] = useState<string | null>(null)
const upload = useMutation((file: File) =>
uploadGerbilPhoto(gerbilId, file, caption.trim() === '' ? null : caption.trim()),
)
const removal = useMutation((photoId: string) => deleteGerbilPhoto(photoId))
async function onUpload(e: FormEvent) {
e.preventDefault()
const file = fileInput.current?.files?.[0]
if (!file) {
setFileError(t.validation.fileRequired)
return
}
setFileError(null)
await upload.run(file)
setCaption('')
if (fileInput.current) fileInput.current.value = ''
photos.reload()
}
async function onDelete(photoId: string) {
if (!window.confirm(t.deleteConfirm)) return
await removal.run(photoId)
photos.reload()
}
const items = photos.data ?? []
const profile = profilePhoto(items)
return (
<div>
<form className="form" onSubmit={onUpload} noValidate>
<h4>{t.uploadTitle}</h4>
<label className="field">
<span>{t.chooseFile}</span>
<input ref={fileInput} type="file" accept="image/*" className="input" />
{fileError && <small className="error-text">{fileError}</small>}
</label>
<label className="field">
<span>{t.captionLabel}</span>
<input className="input" value={caption} onChange={(e) => setCaption(e.target.value)} />
</label>
{upload.error && <div className="alert alert--error">{upload.error}</div>}
<div className="form-actions">
<button type="submit" className="btn btn--primary" disabled={upload.pending}>
{upload.pending ? t.uploading : t.uploadButton}
</button>
</div>
</form>
{photos.loading && <p className="muted">{de.common.loading}</p>}
{photos.error && (
<div className="alert alert--error">
<span>{photos.error}</span>
<button type="button" className="btn" onClick={photos.reload}>
{de.common.retry}
</button>
</div>
)}
{removal.error && <div className="alert alert--error">{removal.error}</div>}
{!photos.loading && !photos.error && items.length === 0 && (
<p className="muted">{t.empty}</p>
)}
{items.length > 0 && (
<ul className="photo-grid">
{[...items]
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((p) => (
<li key={p.id} className="photo-card">
<img src={photoSrc(p)} alt={p.caption ?? p.fileName} loading="lazy" />
<div className="photo-card__bar">
<span className="photo-card__caption">
{profile?.id === p.id && <span className="badge">{t.profileBadge}</span>}{' '}
{p.caption ?? ''}
</span>
<button
type="button"
className="btn btn--danger"
onClick={() => onDelete(p.id)}
disabled={removal.pending}
>
{de.common.delete}
</button>
</div>
</li>
))}
</ul>
)}
</div>
)
}