feat: DogDetail — champion/bloodline badge in header, champion-glow border on main photo
This commit is contained in:
@@ -3,6 +3,7 @@ import { useParams, Link, useNavigate } from 'react-router-dom'
|
|||||||
import { Dog, GitBranch, Edit, Upload, Trash2, ArrowLeft, Calendar, Hash, Award } from 'lucide-react'
|
import { Dog, GitBranch, Edit, Upload, Trash2, ArrowLeft, Calendar, Hash, Award } from 'lucide-react'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import DogForm from '../components/DogForm'
|
import DogForm from '../components/DogForm'
|
||||||
|
import { ChampionBadge, ChampionBloodlineBadge } from '../components/ChampionBadge'
|
||||||
|
|
||||||
function DogDetail() {
|
function DogDetail() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
@@ -14,9 +15,7 @@ function DogDetail() {
|
|||||||
const [selectedPhoto, setSelectedPhoto] = useState(0)
|
const [selectedPhoto, setSelectedPhoto] = useState(0)
|
||||||
const fileInputRef = useRef(null)
|
const fileInputRef = useRef(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { fetchDog() }, [id])
|
||||||
fetchDog()
|
|
||||||
}, [id])
|
|
||||||
|
|
||||||
const fetchDog = async () => {
|
const fetchDog = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -32,11 +31,9 @@ function DogDetail() {
|
|||||||
const handlePhotoUpload = async (e) => {
|
const handlePhotoUpload = async (e) => {
|
||||||
const file = e.target.files[0]
|
const file = e.target.files[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
|
||||||
setUploading(true)
|
setUploading(true)
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('photo', file)
|
formData.append('photo', file)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.post(`/api/dogs/${id}/photos`, formData, {
|
await axios.post(`/api/dogs/${id}/photos`, formData, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' }
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
@@ -53,7 +50,6 @@ function DogDetail() {
|
|||||||
|
|
||||||
const handleDeletePhoto = async (photoIndex) => {
|
const handleDeletePhoto = async (photoIndex) => {
|
||||||
if (!confirm('Delete this photo?')) return
|
if (!confirm('Delete this photo?')) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.delete(`/api/dogs/${id}/photos/${photoIndex}`)
|
await axios.delete(`/api/dogs/${id}/photos/${photoIndex}`)
|
||||||
fetchDog()
|
fetchDog()
|
||||||
@@ -72,24 +68,20 @@ function DogDetail() {
|
|||||||
const birth = new Date(birthDate)
|
const birth = new Date(birthDate)
|
||||||
let years = today.getFullYear() - birth.getFullYear()
|
let years = today.getFullYear() - birth.getFullYear()
|
||||||
let months = today.getMonth() - birth.getMonth()
|
let months = today.getMonth() - birth.getMonth()
|
||||||
|
if (months < 0) { years--; months += 12 }
|
||||||
if (months < 0) {
|
|
||||||
years--
|
|
||||||
months += 12
|
|
||||||
}
|
|
||||||
|
|
||||||
if (years === 0) return `${months} month${months !== 1 ? 's' : ''}`
|
if (years === 0) return `${months} month${months !== 1 ? 's' : ''}`
|
||||||
if (months === 0) return `${years} year${years !== 1 ? 's' : ''}`
|
if (months === 0) return `${years} year${years !== 1 ? 's' : ''}`
|
||||||
return `${years}y ${months}m`
|
return `${years}y ${months}m`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
const hasChampionBlood = (d) =>
|
||||||
return <div className="container loading">Loading...</div>
|
(d.sire && d.sire.is_champion) || (d.dam && d.dam.is_champion)
|
||||||
}
|
|
||||||
|
|
||||||
if (!dog) {
|
if (loading) return <div className="container loading">Loading...</div>
|
||||||
return <div className="container">Dog not found</div>
|
if (!dog) return <div className="container">Dog not found</div>
|
||||||
}
|
|
||||||
|
const isChampion = !!dog.is_champion
|
||||||
|
const hasBloodline = !isChampion && hasChampionBlood(dog)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container" style={{ paddingTop: '2rem', paddingBottom: '3rem' }}>
|
<div className="container" style={{ paddingTop: '2rem', paddingBottom: '3rem' }}>
|
||||||
@@ -99,14 +91,18 @@ function DogDetail() {
|
|||||||
<ArrowLeft size={20} />
|
<ArrowLeft size={20} />
|
||||||
</button>
|
</button>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<h1 style={{ marginBottom: '0.25rem' }}>{dog.name}</h1>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem', flexWrap: 'wrap', marginBottom: '0.25rem' }}>
|
||||||
|
<h1 style={{ margin: 0 }}>{dog.name}</h1>
|
||||||
|
{isChampion && <ChampionBadge size="lg" />}
|
||||||
|
{hasBloodline && <ChampionBloodlineBadge size="lg" />}
|
||||||
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', color: 'var(--text-secondary)' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', color: 'var(--text-secondary)' }}>
|
||||||
<span>{dog.breed}</span>
|
<span>{dog.breed}</span>
|
||||||
<span>•</span>
|
<span>·</span>
|
||||||
<span>{dog.sex === 'male' ? 'Male ♂' : 'Female ♀'}</span>
|
<span>{dog.sex === 'male' ? 'Male ♂' : 'Female ♀'}</span>
|
||||||
{dog.birth_date && (
|
{dog.birth_date && (
|
||||||
<>
|
<>
|
||||||
<span>•</span>
|
<span>·</span>
|
||||||
<span>{calculateAge(dog.birth_date)}</span>
|
<span>{calculateAge(dog.birth_date)}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -125,12 +121,12 @@ function DogDetail() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: '1.5rem', marginBottom: '1.5rem' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: '1.5rem', marginBottom: '1.5rem' }}>
|
||||||
{/* Photo Section - Compact */}
|
{/* Photo Section */}
|
||||||
<div className="card" style={{ padding: '1rem' }}>
|
<div className="card" style={{ padding: '1rem' }}>
|
||||||
<div style={{ marginBottom: '0.75rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div style={{ marginBottom: '0.75rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<h3 style={{ fontSize: '0.875rem', textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)' }}>Photos</h3>
|
<h3 style={{ fontSize: '0.875rem', textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)' }}>Photos</h3>
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
disabled={uploading}
|
disabled={uploading}
|
||||||
style={{ padding: '0.375rem 0.75rem', fontSize: '0.75rem' }}
|
style={{ padding: '0.375rem 0.75rem', fontSize: '0.75rem' }}
|
||||||
@@ -138,46 +134,42 @@ function DogDetail() {
|
|||||||
<Upload size={14} />
|
<Upload size={14} />
|
||||||
{uploading ? 'Uploading...' : 'Add'}
|
{uploading ? 'Uploading...' : 'Add'}
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input ref={fileInputRef} type="file" accept="image/*" onChange={handlePhotoUpload} style={{ display: 'none' }} />
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={handlePhotoUpload}
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{dog.photo_urls && dog.photo_urls.length > 0 ? (
|
{dog.photo_urls && dog.photo_urls.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
{/* Main Photo */}
|
|
||||||
<div style={{ position: 'relative', marginBottom: '0.75rem' }}>
|
<div style={{ position: 'relative', marginBottom: '0.75rem' }}>
|
||||||
<img
|
<img
|
||||||
src={dog.photo_urls[selectedPhoto]}
|
src={dog.photo_urls[selectedPhoto]}
|
||||||
alt={dog.name}
|
alt={dog.name}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%', aspectRatio: '1', objectFit: 'cover',
|
||||||
aspectRatio: '1',
|
|
||||||
objectFit: 'cover',
|
|
||||||
borderRadius: 'var(--radius)',
|
borderRadius: 'var(--radius)',
|
||||||
border: '1px solid var(--border)'
|
border: isChampion
|
||||||
}}
|
? '2px solid var(--champion-gold)'
|
||||||
|
: hasBloodline
|
||||||
|
? '2px solid var(--bloodline-amber)'
|
||||||
|
: '1px solid var(--border)',
|
||||||
|
boxShadow: isChampion
|
||||||
|
? '0 0 12px var(--champion-glow)'
|
||||||
|
: hasBloodline
|
||||||
|
? '0 0 10px var(--bloodline-glow)'
|
||||||
|
: 'none'
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
className="btn-icon"
|
className="btn-icon"
|
||||||
onClick={() => handleDeletePhoto(selectedPhoto)}
|
onClick={() => handleDeletePhoto(selectedPhoto)}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute', top: '0.5rem', right: '0.5rem',
|
||||||
top: '0.5rem',
|
background: 'rgba(14, 15, 12, 0.8)',
|
||||||
right: '0.5rem',
|
|
||||||
background: 'rgba(15, 23, 42, 0.8)',
|
|
||||||
backdropFilter: 'blur(8px)'
|
backdropFilter: 'blur(8px)'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 size={16} color="var(--danger)" />
|
<Trash2 size={16} color="var(--danger)" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Thumbnail Strip */}
|
|
||||||
{dog.photo_urls.length > 1 && (
|
{dog.photo_urls.length > 1 && (
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', overflowX: 'auto' }}>
|
<div style={{ display: 'flex', gap: '0.5rem', overflowX: 'auto' }}>
|
||||||
{dog.photo_urls.map((url, index) => (
|
{dog.photo_urls.map((url, index) => (
|
||||||
@@ -187,9 +179,7 @@ function DogDetail() {
|
|||||||
alt={`${dog.name} ${index + 1}`}
|
alt={`${dog.name} ${index + 1}`}
|
||||||
onClick={() => setSelectedPhoto(index)}
|
onClick={() => setSelectedPhoto(index)}
|
||||||
style={{
|
style={{
|
||||||
width: '60px',
|
width: '60px', height: '60px', objectFit: 'cover',
|
||||||
height: '60px',
|
|
||||||
objectFit: 'cover',
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
borderRadius: 'var(--radius-sm)',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
border: selectedPhoto === index ? '2px solid var(--primary)' : '1px solid var(--border)',
|
border: selectedPhoto === index ? '2px solid var(--primary)' : '1px solid var(--border)',
|
||||||
@@ -213,18 +203,26 @@ function DogDetail() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
||||||
<h2 style={{ fontSize: '1rem', marginBottom: '1rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Details</h2>
|
<h2 style={{ fontSize: '1rem', marginBottom: '1rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Details</h2>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="info-row">
|
<div className="info-row">
|
||||||
<span className="info-label">Breed</span>
|
<span className="info-label">Breed</span>
|
||||||
<span className="info-value">{dog.breed}</span>
|
<span className="info-value">{dog.breed}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="info-row">
|
<div className="info-row">
|
||||||
<span className="info-label">Sex</span>
|
<span className="info-label">Sex</span>
|
||||||
<span className="info-value">{dog.sex === 'male' ? 'Male ♂' : 'Female ♀'}</span>
|
<span className="info-value">{dog.sex === 'male' ? 'Male ♂' : 'Female ♀'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="info-row">
|
||||||
|
<span className="info-label">Champion</span>
|
||||||
|
<span className="info-value">
|
||||||
|
{isChampion
|
||||||
|
? <ChampionBadge size="lg" />
|
||||||
|
: hasBloodline
|
||||||
|
? <ChampionBloodlineBadge size="lg" />
|
||||||
|
: <span style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>—</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{dog.birth_date && (
|
{dog.birth_date && (
|
||||||
<div className="info-row">
|
<div className="info-row">
|
||||||
<span className="info-label"><Calendar size={14} style={{ display: 'inline', marginRight: '0.25rem' }} />Birth Date</span>
|
<span className="info-label"><Calendar size={14} style={{ display: 'inline', marginRight: '0.25rem' }} />Birth Date</span>
|
||||||
@@ -234,21 +232,18 @@ function DogDetail() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{dog.color && (
|
{dog.color && (
|
||||||
<div className="info-row">
|
<div className="info-row">
|
||||||
<span className="info-label">Color</span>
|
<span className="info-label">Color</span>
|
||||||
<span className="info-value">{dog.color}</span>
|
<span className="info-value">{dog.color}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{dog.registration_number && (
|
{dog.registration_number && (
|
||||||
<div className="info-row">
|
<div className="info-row">
|
||||||
<span className="info-label"><Award size={14} style={{ display: 'inline', marginRight: '0.25rem' }} />Registration</span>
|
<span className="info-label"><Award size={14} style={{ display: 'inline', marginRight: '0.25rem' }} />Registration</span>
|
||||||
<span className="info-value" style={{ fontFamily: 'monospace' }}>{dog.registration_number}</span>
|
<span className="info-value" style={{ fontFamily: 'monospace' }}>{dog.registration_number}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{dog.microchip && (
|
{dog.microchip && (
|
||||||
<div className="info-row">
|
<div className="info-row">
|
||||||
<span className="info-label"><Hash size={14} style={{ display: 'inline', marginRight: '0.25rem' }} />Microchip</span>
|
<span className="info-label"><Hash size={14} style={{ display: 'inline', marginRight: '0.25rem' }} />Microchip</span>
|
||||||
@@ -265,9 +260,12 @@ function DogDetail() {
|
|||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: '0.8125rem', color: 'var(--text-muted)', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Sire</div>
|
<div style={{ fontSize: '0.8125rem', color: 'var(--text-muted)', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Sire</div>
|
||||||
{dog.sire ? (
|
{dog.sire ? (
|
||||||
<Link to={`/dogs/${dog.sire.id}`} style={{ color: 'var(--primary)', fontWeight: 500, textDecoration: 'none' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', flexWrap: 'wrap' }}>
|
||||||
{dog.sire.name}
|
<Link to={`/dogs/${dog.sire.id}`} style={{ color: 'var(--primary)', fontWeight: 500, textDecoration: 'none' }}>
|
||||||
</Link>
|
{dog.sire.name}
|
||||||
|
</Link>
|
||||||
|
{dog.sire.is_champion && <ChampionBadge />}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>Unknown</span>
|
<span style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>Unknown</span>
|
||||||
)}
|
)}
|
||||||
@@ -275,9 +273,12 @@ function DogDetail() {
|
|||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: '0.8125rem', color: 'var(--text-muted)', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Dam</div>
|
<div style={{ fontSize: '0.8125rem', color: 'var(--text-muted)', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Dam</div>
|
||||||
{dog.dam ? (
|
{dog.dam ? (
|
||||||
<Link to={`/dogs/${dog.dam.id}`} style={{ color: 'var(--primary)', fontWeight: 500, textDecoration: 'none' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', flexWrap: 'wrap' }}>
|
||||||
{dog.dam.name}
|
<Link to={`/dogs/${dog.dam.id}`} style={{ color: 'var(--primary)', fontWeight: 500, textDecoration: 'none' }}>
|
||||||
</Link>
|
{dog.dam.name}
|
||||||
|
</Link>
|
||||||
|
{dog.dam.is_champion && <ChampionBadge />}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>Unknown</span>
|
<span style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>Unknown</span>
|
||||||
)}
|
)}
|
||||||
@@ -301,19 +302,20 @@ function DogDetail() {
|
|||||||
<h2 style={{ fontSize: '1rem', marginBottom: '1rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Offspring ({dog.offspring.length})</h2>
|
<h2 style={{ fontSize: '1rem', marginBottom: '1rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Offspring ({dog.offspring.length})</h2>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: '0.75rem' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: '0.75rem' }}>
|
||||||
{dog.offspring.map(child => (
|
{dog.offspring.map(child => (
|
||||||
<Link
|
<Link
|
||||||
key={child.id}
|
key={child.id}
|
||||||
to={`/dogs/${child.id}`}
|
to={`/dogs/${child.id}`}
|
||||||
style={{
|
style={{
|
||||||
padding: '0.75rem 1rem',
|
padding: '0.75rem 1rem',
|
||||||
background: 'var(--bg-primary)',
|
background: 'var(--bg-primary)',
|
||||||
border: '1px solid var(--border)',
|
border: '1px solid var(--border)',
|
||||||
borderRadius: 'var(--radius-sm)',
|
borderRadius: 'var(--radius-sm)',
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
transition: 'var(--transition)',
|
transition: 'var(--transition)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center'
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem'
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.borderColor = 'var(--primary)'
|
e.currentTarget.style.borderColor = 'var(--primary)'
|
||||||
@@ -325,7 +327,10 @@ function DogDetail() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ color: 'var(--text-primary)', fontWeight: 500 }}>{child.name}</span>
|
<span style={{ color: 'var(--text-primary)', fontWeight: 500 }}>{child.name}</span>
|
||||||
<span style={{ fontSize: '1.125rem' }}>{child.sex === 'male' ? '♂' : '♀'}</span>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
|
||||||
|
{child.is_champion && <ChampionBadge />}
|
||||||
|
<span style={{ fontSize: '1.125rem' }}>{child.sex === 'male' ? '♂' : '♀'}</span>
|
||||||
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -336,14 +341,11 @@ function DogDetail() {
|
|||||||
<DogForm
|
<DogForm
|
||||||
dog={dog}
|
dog={dog}
|
||||||
onClose={() => setShowEditModal(false)}
|
onClose={() => setShowEditModal(false)}
|
||||||
onSave={() => {
|
onSave={() => { fetchDog(); setShowEditModal(false) }}
|
||||||
fetchDog()
|
|
||||||
setShowEditModal(false)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default DogDetail
|
export default DogDetail
|
||||||
|
|||||||
Reference in New Issue
Block a user