feat: Rennmausakte, Zucht-Suffix, Gehege-Bilder, Toasts, Infinite-Scroll
Detailseite (Rennmausakte): - Neugestaltung: Hero-Foto + Overlay, Schnellfakten-Pills, Karten-Sektionen, Charakter als Chips, getabte Fotos/Gesundheit/Gewicht. - Eltern (Vater/Mutter) als Links; Charakter-Karte klappt bei Status Abgabe/Verstorben/Abgegeben ein (nur Zucht/Liebhaber offen). - Gehege wird bei abgegebenen/verstorbenen Tieren ausgeblendet (Detail + Formular). Listen: - Infinite Scroll auf allen Listen (Rennmäuse, Würfe, Gehege, Verträge, Anfragen, Kontakte) via useInfiniteList/useInfiniteSentinel; stabile Sortierung mit id-Tiebreaker (keine doppelten Keys), Back-to-top-Button. - Kontakte: Rolle-Filter (Züchter/Abnehmer) als Quick-Chips + Sticky-Header. Zucht-Nachname: - Namens-Anhängsel der Zucht in den Einstellungen + je Züchter-Kontakt (Backend-Spalten + Migration); eigene Tiere zeigen „Name + Suffix". - „Eigene Zucht" ist die Standard-Herkunft neuer Tiere. Weiteres: - Gehege-Bilder: Upload/Galerie auf der Gehege-Detailseite (Backend EnclosurePhoto + Endpoints + Migration, geteilte Dateiablage). - Toast-Rückmeldungen für alle Speichern-Aktionen. - Checkboxen durch mobile-freundliche Toggle-Schalter ersetzt. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import type { ReactNode } from 'react'
|
||||
import { NavLink, Outlet } from 'react-router-dom'
|
||||
import { de } from '../strings/de'
|
||||
import GerbilIcon from './GerbilIcon'
|
||||
import { BreederSuffixProvider } from './BreederSuffixProvider'
|
||||
import './appShell.css'
|
||||
|
||||
interface NavItem {
|
||||
@@ -83,7 +84,9 @@ export default function AppShell() {
|
||||
</nav>
|
||||
|
||||
<main className="app-main">
|
||||
<Outlet />
|
||||
<BreederSuffixProvider>
|
||||
<Outlet />
|
||||
</BreederSuffixProvider>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
|
||||
17
gerbil-manager-web/src/components/BreederSuffixProvider.tsx
Normal file
17
gerbil-manager-web/src/components/BreederSuffixProvider.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* ZUCHT-SUFFIX: provides the own-Zucht name suffix (from the breeder profile) once
|
||||
* to the whole app. Consumers use `useGerbilName()` from ./breederSuffix.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import { getBreederProfile } from '../api/settings'
|
||||
import { useApi } from '../hooks/useApi'
|
||||
import { OwnSuffixContext } from './breederSuffix'
|
||||
|
||||
export function BreederSuffixProvider({ children }: { children: ReactNode }) {
|
||||
const profile = useApi(() => getBreederProfile(), [])
|
||||
return (
|
||||
<OwnSuffixContext.Provider value={profile.data?.nameSuffix ?? ''}>
|
||||
{children}
|
||||
</OwnSuffixContext.Provider>
|
||||
)
|
||||
}
|
||||
116
gerbil-manager-web/src/components/EnclosurePhotosSection.tsx
Normal file
116
gerbil-manager-web/src/components/EnclosurePhotosSection.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
/** Bilder eines Gehges — Galerie-Raster + Upload + Löschen (wie der Tier-Fotos-Tab). */
|
||||
import { useRef, useState, type FormEvent } from 'react'
|
||||
import { de } from '../strings/de'
|
||||
import {
|
||||
deleteEnclosurePhoto,
|
||||
listEnclosurePhotos,
|
||||
photoSrc,
|
||||
uploadEnclosurePhoto,
|
||||
} from '../api/enclosurePhotos'
|
||||
import { useApi, useMutation } from '../hooks/useApi'
|
||||
import { useToast } from './toast'
|
||||
|
||||
export default function EnclosurePhotosSection({ enclosureId }: { enclosureId: string }) {
|
||||
const tp = de.pages.tierTabs.photos
|
||||
const toast = useToast()
|
||||
|
||||
const photos = useApi(() => listEnclosurePhotos(enclosureId), [enclosureId])
|
||||
const fileInput = useRef<HTMLInputElement>(null)
|
||||
const [caption, setCaption] = useState('')
|
||||
const [fileError, setFileError] = useState<string | null>(null)
|
||||
|
||||
const upload = useMutation((file: File) =>
|
||||
uploadEnclosurePhoto(enclosureId, file, caption.trim() === '' ? null : caption.trim()),
|
||||
)
|
||||
const removal = useMutation((photoId: string) => deleteEnclosurePhoto(photoId))
|
||||
|
||||
async function onUpload(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
const file = fileInput.current?.files?.[0]
|
||||
if (!file) {
|
||||
setFileError(tp.validation.fileRequired)
|
||||
return
|
||||
}
|
||||
setFileError(null)
|
||||
const r = await upload.run(file)
|
||||
if (r.ok) {
|
||||
toast.success(de.common.saved)
|
||||
setCaption('')
|
||||
if (fileInput.current) fileInput.current.value = ''
|
||||
photos.reload()
|
||||
} else {
|
||||
toast.error(r.error)
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(photoId: string) {
|
||||
if (!window.confirm(tp.deleteConfirm)) return
|
||||
const r = await removal.run(photoId)
|
||||
if (r.ok) {
|
||||
toast.success(de.common.deleted)
|
||||
photos.reload()
|
||||
} else {
|
||||
toast.error(r.error)
|
||||
}
|
||||
}
|
||||
|
||||
const items = photos.data ?? []
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form className="form" onSubmit={onUpload} noValidate>
|
||||
<label className="field">
|
||||
<span>{tp.chooseFile}</span>
|
||||
<input ref={fileInput} type="file" accept="image/*" className="input" />
|
||||
{fileError && <small className="error-text">{fileError}</small>}
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{tp.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 ? tp.uploading : tp.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">{tp.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">{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>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +1,26 @@
|
||||
/**
|
||||
* FEAT-6: Profilfoto im Detail-Kopf — erstes Foto nach sortOrder.
|
||||
* Selbstständig ladend; rendert nichts, solange es keine Fotos gibt
|
||||
* (oder die Foto-Endpunkte noch nicht live sind).
|
||||
* FEAT-6: Profilfoto — erstes Foto nach sortOrder. Selbstständig ladend.
|
||||
* Ohne Foto rendert es `fallback` (oder nichts).
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import { listGerbilPhotos, photoSrc, profilePhoto } from '../api/photos'
|
||||
import { useApi } from '../hooks/useApi'
|
||||
|
||||
export default function GerbilProfilePhoto({ gerbilId }: { gerbilId: string }) {
|
||||
interface GerbilProfilePhotoProps {
|
||||
gerbilId: string
|
||||
/** CSS class for the <img> (default: the small detail-header avatar). */
|
||||
className?: string
|
||||
/** Rendered when the gerbil has no photo yet (e.g. a hero placeholder). */
|
||||
fallback?: ReactNode
|
||||
}
|
||||
|
||||
export default function GerbilProfilePhoto({
|
||||
gerbilId,
|
||||
className = 'profile-photo',
|
||||
fallback = null,
|
||||
}: GerbilProfilePhotoProps) {
|
||||
const photos = useApi(() => listGerbilPhotos(gerbilId), [gerbilId])
|
||||
const profile = profilePhoto(photos.data ?? [])
|
||||
if (!profile) return null
|
||||
return (
|
||||
<img
|
||||
className="profile-photo"
|
||||
src={photoSrc(profile)}
|
||||
alt={profile.caption ?? profile.fileName}
|
||||
/>
|
||||
)
|
||||
if (!profile) return <>{fallback}</>
|
||||
return <img className={className} src={photoSrc(profile)} alt={profile.caption ?? profile.fileName} />
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { formatDate, genderLabel } from '../format/labels'
|
||||
import { traitLabels } from '../format/traits'
|
||||
import FarbschlagImage from './FarbschlagImage'
|
||||
import Charakterbogen from './Charakterbogen'
|
||||
import { useGerbilName } from './breederSuffix'
|
||||
|
||||
type SaleStatus = 'free' | 'loose' | 'reserved'
|
||||
|
||||
@@ -32,6 +33,7 @@ function groupGender(animals: Gerbil[]): string {
|
||||
|
||||
export default function GroupComposer({ groupNumber, animals, farbschlagOf }: GroupComposerProps) {
|
||||
const t = de.pages.abgabe
|
||||
const gerbilName = useGerbilName()
|
||||
const navigate = useNavigate()
|
||||
const [status, setStatus] = useState<SaleStatus>('free')
|
||||
const [reservedName, setReservedName] = useState('')
|
||||
@@ -203,7 +205,7 @@ export default function GroupComposer({ groupNumber, animals, farbschlagOf }: Gr
|
||||
<div key={a.id} className="group-composer__animal">
|
||||
<div className="group-composer__animal-head">
|
||||
<FarbschlagImage name={farbschlagOf(a)} size={36} />
|
||||
<strong>{a.name}</strong>
|
||||
<strong>{gerbilName(a) || a.name}</strong>
|
||||
<span className="muted">
|
||||
{farbschlagOf(a)} · {genderLabel(a.gender)}
|
||||
{a.dateOfBirth ? ` · ${t.listing.bornOn} ${formatDate(a.dateOfBirth)}` : ''}
|
||||
|
||||
43
gerbil-manager-web/src/components/ToastProvider.tsx
Normal file
43
gerbil-manager-web/src/components/ToastProvider.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
/** Toast notifications: provider + auto-dismissing toast stack. */
|
||||
import { useCallback, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { ToastContext, type ToastApi, type ToastVariant } from './toast'
|
||||
import './toast.css'
|
||||
|
||||
interface ToastItem {
|
||||
id: number
|
||||
message: string
|
||||
variant: ToastVariant
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([])
|
||||
const idRef = useRef(0)
|
||||
const remove = useCallback((id: number) => setToasts((ts) => ts.filter((t) => t.id !== id)), [])
|
||||
|
||||
const api = useMemo<ToastApi>(() => {
|
||||
const show = (message: string, variant: ToastVariant = 'info') => {
|
||||
const id = (idRef.current += 1)
|
||||
setToasts((ts) => [...ts, { id, message, variant }])
|
||||
window.setTimeout(() => remove(id), 3500)
|
||||
}
|
||||
return { show, success: (m) => show(m, 'success'), error: (m) => show(m, 'error') }
|
||||
}, [remove])
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={api}>
|
||||
{children}
|
||||
<div className="toast-stack" role="status" aria-live="polite">
|
||||
{toasts.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
className={`toast toast--${t.variant}`}
|
||||
onClick={() => remove(t.id)}
|
||||
>
|
||||
{t.message}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
17
gerbil-manager-web/src/components/breederSuffix.ts
Normal file
17
gerbil-manager-web/src/components/breederSuffix.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* ZUCHT-SUFFIX: the own-Zucht name-suffix context + the `useGerbilName()` helper.
|
||||
* Kept separate from the provider component so each module has a single concern
|
||||
* (and satisfies react-refresh's "components-only" rule for .tsx files).
|
||||
*/
|
||||
import { createContext, useContext } from 'react'
|
||||
import { gerbilDisplayName } from '../format/gerbilName'
|
||||
import type { Gerbil } from '../api/types'
|
||||
|
||||
export const OwnSuffixContext = createContext<string>('')
|
||||
|
||||
/** Returns a function that renders a gerbil's display name incl. the Zucht suffix. */
|
||||
export function useGerbilName() {
|
||||
const ownSuffix = useContext(OwnSuffixContext)
|
||||
return (g: Pick<Gerbil, 'name' | 'originContactId' | 'originBreeder'>): string =>
|
||||
gerbilDisplayName(g, ownSuffix)
|
||||
}
|
||||
55
gerbil-manager-web/src/components/toast.css
Normal file
55
gerbil-manager-web/src/components/toast.css
Normal file
@@ -0,0 +1,55 @@
|
||||
/* Toast stack: centered above the mobile action bar (normal bottom inset on desktop). */
|
||||
.toast-stack {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: calc(5rem + env(safe-area-inset-bottom));
|
||||
transform: translateX(-50%);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
width: max-content;
|
||||
max-width: calc(100vw - 2rem);
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: var(--color-text);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
padding: 0.7rem 1.1rem;
|
||||
box-shadow: 0 6px 24px rgba(43, 33, 25, 0.28);
|
||||
text-align: center;
|
||||
animation: toastIn 0.22s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
@keyframes toastIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
.toast--success {
|
||||
background: #5e8c5a;
|
||||
}
|
||||
.toast--error {
|
||||
background: #a6433a;
|
||||
}
|
||||
.toast--info {
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.toast-stack {
|
||||
bottom: 1.5rem;
|
||||
}
|
||||
}
|
||||
19
gerbil-manager-web/src/components/toast.ts
Normal file
19
gerbil-manager-web/src/components/toast.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/** Toast notifications: the context + `useToast()` hook (kept separate from the
|
||||
* provider component so each module has a single concern / fast-refresh stays happy). */
|
||||
import { createContext, useContext } from 'react'
|
||||
|
||||
export type ToastVariant = 'success' | 'error' | 'info'
|
||||
|
||||
export interface ToastApi {
|
||||
show: (message: string, variant?: ToastVariant) => void
|
||||
success: (message: string) => void
|
||||
error: (message: string) => void
|
||||
}
|
||||
|
||||
const NOOP: ToastApi = { show: () => {}, success: () => {}, error: () => {} }
|
||||
|
||||
export const ToastContext = createContext<ToastApi>(NOOP)
|
||||
|
||||
export function useToast(): ToastApi {
|
||||
return useContext(ToastContext)
|
||||
}
|
||||
Reference in New Issue
Block a user