119 lines
3.9 KiB
TypeScript
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>
|
|
)
|
|
}
|