FEAT-6: Gesundheit/Gewicht/Fotos tab components + hand-rolled SVG LineChart (no chart dependency)
This commit is contained in:
118
gerbil-manager-web/src/components/GerbilPhotosTab.tsx
Normal file
118
gerbil-manager-web/src/components/GerbilPhotosTab.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user