Merge pull request 'feat/ui-theme-settings-champion' (#29) from feat/ui-theme-settings-champion into master
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled

Reviewed-on: #29
This commit was merged in pull request #29.
This commit is contained in:
2026-03-09 22:26:17 -05:00
14 changed files with 934 additions and 837 deletions

View File

@@ -27,16 +27,16 @@
gap: 0.75rem; gap: 0.75rem;
color: var(--text-primary); color: var(--text-primary);
font-weight: 700; font-weight: 700;
font-size: 2.25rem; /* +30% from 1.5rem */ font-size: 2.25rem;
text-decoration: none; text-decoration: none;
transition: var(--transition); transition: var(--transition);
} }
.nav-brand:hover { .nav-brand:hover {
color: var(--primary-light); opacity: 0.9;
} }
/* Square logo: doubled from 2.5rem to 5rem */ /* Square logo */
.brand-logo { .brand-logo {
width: 5rem; width: 5rem;
height: 5rem; height: 5rem;
@@ -45,7 +45,6 @@
display: block; display: block;
border-radius: 4px; border-radius: 4px;
flex-shrink: 0; flex-shrink: 0;
/* Subtle diffuse black drop shadow for depth */
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.45)) filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.45))
drop-shadow(0 1px 2px rgba(0, 0, 0, 0.30)); drop-shadow(0 1px 2px rgba(0, 0, 0, 0.30));
} }
@@ -58,7 +57,7 @@
height: 5rem; height: 5rem;
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%); background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
border-radius: var(--radius); border-radius: var(--radius);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); box-shadow: 0 4px 12px rgba(194, 134, 42, 0.3);
} }
/* Title gradient: medium-dark gold → rusty dark red-gold */ /* Title gradient: medium-dark gold → rusty dark red-gold */
@@ -68,7 +67,6 @@
-webkit-background-clip: text; -webkit-background-clip: text;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
background-clip: text; background-clip: text;
/* text-shadow doesn't work with background-clip:text — use filter instead */
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.50)) filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.50))
drop-shadow(0 1px 2px rgba(0, 0, 0, 0.30)); drop-shadow(0 1px 2px rgba(0, 0, 0, 0.30));
} }
@@ -76,6 +74,7 @@
.nav-links { .nav-links {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
align-items: center;
} }
.nav-link { .nav-link {
@@ -99,9 +98,22 @@
} }
.nav-link.active { .nav-link.active {
background: var(--primary); background: linear-gradient(135deg, rgba(201,148,10,0.2) 0%, rgba(139,37,0,0.2) 100%);
color: white; color: var(--primary-light);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); border-color: rgba(194, 134, 42, 0.4);
box-shadow: 0 2px 8px rgba(194, 134, 42, 0.15);
}
/* Settings link — slightly different treatment, sits at end */
.nav-link-settings {
margin-left: 0.5rem;
padding: 0.5rem;
border-radius: var(--radius-sm);
color: var(--text-muted);
}
.nav-link-settings:hover {
color: var(--primary-light);
} }
.main-content { .main-content {
@@ -114,10 +126,9 @@
} }
.nav-brand { .nav-brand {
font-size: 1.625rem; /* +30% from 1.25rem */ font-size: 1.625rem;
} }
/* Scale square logo down on mobile (doubled from 2rem) */
.brand-logo { .brand-logo {
width: 4rem; width: 4rem;
height: 4rem; height: 4rem;

View File

@@ -1,5 +1,5 @@
import { BrowserRouter as Router, Routes, Route, Link} from 'react-router-dom' import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom'
import { Home, Users, Activity, Heart, FlaskConical } from 'lucide-react' import { Home, Users, Activity, Heart, FlaskConical, Settings } from 'lucide-react'
import Dashboard from './pages/Dashboard' import Dashboard from './pages/Dashboard'
import DogList from './pages/DogList' import DogList from './pages/DogList'
import DogDetail from './pages/DogDetail' import DogDetail from './pages/DogDetail'
@@ -8,60 +8,69 @@ import LitterList from './pages/LitterList'
import LitterDetail from './pages/LitterDetail' import LitterDetail from './pages/LitterDetail'
import BreedingCalendar from './pages/BreedingCalendar' import BreedingCalendar from './pages/BreedingCalendar'
import PairingSimulator from './pages/PairingSimulator' import PairingSimulator from './pages/PairingSimulator'
import SettingsPage from './pages/SettingsPage'
import { useSettings } from './hooks/useSettings'
import './App.css' import './App.css'
function NavLink({ to, icon: Icon, label }) {
const location = useLocation()
const isActive = location.pathname === to
return (
<Link to={to} className={`nav-link${isActive ? ' active' : ''}`}>
<Icon size={20} />
<span>{label}</span>
</Link>
)
}
function AppInner() {
const { settings } = useSettings()
const kennelName = settings?.kennel_name || 'BREEDR'
return (
<div className="app">
<nav className="navbar">
<div className="container">
<div className="nav-brand">
<img
src="/static/br-logo.png"
alt="BREEDR Logo"
className="brand-logo"
/>
<span className="brand-text">{kennelName}</span>
</div>
<div className="nav-links">
<NavLink to="/" icon={Home} label="Dashboard" />
<NavLink to="/dogs" icon={Users} label="Dogs" />
<NavLink to="/litters" icon={Activity} label="Litters" />
<NavLink to="/breeding" icon={Heart} label="Breeding" />
<NavLink to="/pairing" icon={FlaskConical} label="Pairing" />
<NavLink to="/settings" icon={Settings} label="Settings" />
</div>
</div>
</nav>
<main className="main-content">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/dogs" element={<DogList />} />
<Route path="/dogs/:id" element={<DogDetail />} />
<Route path="/pedigree/:id" element={<PedigreeView />} />
<Route path="/litters" element={<LitterList />} />
<Route path="/litters/:id" element={<LitterDetail />} />
<Route path="/breeding" element={<BreedingCalendar />} />
<Route path="/pairing" element={<PairingSimulator />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
</main>
</div>
)
}
function App() { function App() {
return ( return (
<Router> <Router>
<div className="app"> <AppInner />
<nav className="navbar">
<div className="container">
<div className="nav-brand">
<img
src="/static/br-logo.png"
alt="BREEDR Logo"
className="brand-logo"
/>
<span className="brand-text">BREEDR</span>
</div>
<div className="nav-links">
<Link to="/" className="nav-link">
<Home size={20} />
<span>Dashboard</span>
</Link>
<Link to="/dogs" className="nav-link">
<Users size={20} />
<span>Dogs</span>
</Link>
<Link to="/litters" className="nav-link">
<Activity size={20} />
<span>Litters</span>
</Link>
<Link to="/breeding" className="nav-link">
<Heart size={20} />
<span>Breeding</span>
</Link>
<Link to="/pairing" className="nav-link">
<FlaskConical size={20} />
<span>Pairing</span>
</Link>
</div>
</div>
</nav>
<main className="main-content">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/dogs" element={<DogList />} />
<Route path="/dogs/:id" element={<DogDetail />} />
<Route path="/pedigree/:id" element={<PedigreeView />} />
<Route path="/litters" element={<LitterList />} />
<Route path="/litters/:id" element={<LitterDetail />} />
<Route path="/breeding" element={<BreedingCalendar />} />
<Route path="/pairing" element={<PairingSimulator />} />
</Routes>
</main>
</div>
</Router> </Router>
) )
} }

View File

@@ -0,0 +1,52 @@
/**
* ChampionBadge — shown on dogs with is_champion = 1
* ChampionBloodlineBadge — shown on dogs whose sire OR dam is a champion
*
* Usage:
* <ChampionBadge />
* <ChampionBloodlineBadge />
*/
export function ChampionBadge({ size = 'sm' }) {
return (
<span
className="badge-champion"
title="AKC / Registry Champion"
style={size === 'lg' ? { fontSize: '0.8rem', padding: '0.3rem 0.7rem' } : {}}
>
{/* Crown SVG inline — no extra dep */}
<svg
width={size === 'lg' ? 14 : 11}
height={size === 'lg' ? 14 : 11}
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path d="M2 15h16v2H2v-2zm0-2 3-7 5 4 5-4 3 7H2z" />
</svg>
CH
</span>
)
}
export function ChampionBloodlineBadge({ size = 'sm' }) {
return (
<span
className="badge-bloodline"
title="Direct descendant of a champion"
style={size === 'lg' ? { fontSize: '0.8rem', padding: '0.3rem 0.7rem' } : {}}
>
{/* Droplet / bloodline SVG */}
<svg
width={size === 'lg' ? 13 : 10}
height={size === 'lg' ? 13 : 10}
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d="M12 2C8 8 5 12 5 15.5a7 7 0 0 0 14 0C19 12 16 8 12 2z" />
</svg>
BL
</span>
)
}

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { X } from 'lucide-react' import { X, Award } from 'lucide-react'
import axios from 'axios' import axios from 'axios'
function DogForm({ dog, onClose, onSave }) { function DogForm({ dog, onClose, onSave }) {
@@ -12,9 +12,10 @@ function DogForm({ dog, onClose, onSave }) {
color: '', color: '',
microchip: '', microchip: '',
notes: '', notes: '',
sire_id: null, // Changed from '' to null sire_id: null,
dam_id: null, // Changed from '' to null dam_id: null,
litter_id: null // Changed from '' to null litter_id: null,
is_champion: false,
}) })
const [dogs, setDogs] = useState([]) const [dogs, setDogs] = useState([])
const [litters, setLitters] = useState([]) const [litters, setLitters] = useState([])
@@ -36,9 +37,10 @@ function DogForm({ dog, onClose, onSave }) {
color: dog.color || '', color: dog.color || '',
microchip: dog.microchip || '', microchip: dog.microchip || '',
notes: dog.notes || '', notes: dog.notes || '',
sire_id: dog.sire?.id || null, // Ensure null, not '' sire_id: dog.sire?.id || null,
dam_id: dog.dam?.id || null, // Ensure null, not '' dam_id: dog.dam?.id || null,
litter_id: dog.litter_id || null // Ensure null, not '' litter_id: dog.litter_id || null,
is_champion: !!dog.is_champion,
}) })
setUseManualParents(!dog.litter_id) setUseManualParents(!dog.litter_id)
} }
@@ -48,8 +50,7 @@ function DogForm({ dog, onClose, onSave }) {
try { try {
const res = await axios.get('/api/dogs') const res = await axios.get('/api/dogs')
setDogs(res.data || []) setDogs(res.data || [])
} catch (error) { } catch (e) {
console.error('Error fetching dogs:', error)
setDogs([]) setDogs([])
} }
} }
@@ -57,16 +58,11 @@ function DogForm({ dog, onClose, onSave }) {
const fetchLitters = async () => { const fetchLitters = async () => {
try { try {
const res = await axios.get('/api/litters') const res = await axios.get('/api/litters')
const litterData = res.data || [] const data = res.data || []
setLitters(litterData) setLitters(data)
setLittersAvailable(litterData.length > 0) setLittersAvailable(data.length > 0)
// Only default to manual if no litters exist if (data.length === 0) setUseManualParents(true)
if (litterData.length === 0) { } catch (e) {
setUseManualParents(true)
}
} catch (error) {
console.error('Error fetching litters:', error)
// If endpoint fails, gracefully fallback to manual mode
setLitters([]) setLitters([])
setLittersAvailable(false) setLittersAvailable(false)
setUseManualParents(true) setUseManualParents(true)
@@ -74,25 +70,27 @@ function DogForm({ dog, onClose, onSave }) {
} }
const handleChange = (e) => { const handleChange = (e) => {
const { name, value } = e.target const { name, value, type, checked } = e.target
// Convert empty strings to null for ID fields if (type === 'checkbox') {
let processedValue = value setFormData(prev => ({ ...prev, [name]: checked }))
if (name === 'sire_id' || name === 'dam_id' || name === 'litter_id') { return
processedValue = value === '' ? null : parseInt(value)
} }
setFormData(prev => ({ ...prev, [name]: processedValue })) let processed = value
if (name === 'sire_id' || name === 'dam_id' || name === 'litter_id') {
processed = value === '' ? null : parseInt(value)
}
setFormData(prev => ({ ...prev, [name]: processed }))
// If litter is selected, auto-populate parents
if (name === 'litter_id' && value) { if (name === 'litter_id' && value) {
const selectedLitter = litters.find(l => l.id === parseInt(value)) const sel = litters.find(l => l.id === parseInt(value))
if (selectedLitter) { if (sel) {
setFormData(prev => ({ setFormData(prev => ({
...prev, ...prev,
sire_id: selectedLitter.sire_id, sire_id: sel.sire_id,
dam_id: selectedLitter.dam_id, dam_id: sel.dam_id,
breed: prev.breed || selectedLitter.sire_name?.split(' ')[0] || '' breed: prev.breed || sel.sire_name?.split(' ')[0] || ''
})) }))
} }
} }
@@ -102,11 +100,10 @@ function DogForm({ dog, onClose, onSave }) {
e.preventDefault() e.preventDefault()
setError('') setError('')
setLoading(true) setLoading(true)
try { try {
const submitData = { const submitData = {
...formData, ...formData,
// Ensure null values are sent, not empty strings is_champion: formData.is_champion ? 1 : 0,
sire_id: formData.sire_id || null, sire_id: formData.sire_id || null,
dam_id: formData.dam_id || null, dam_id: formData.dam_id || null,
litter_id: useManualParents ? null : (formData.litter_id || null), litter_id: useManualParents ? null : (formData.litter_id || null),
@@ -114,25 +111,22 @@ function DogForm({ dog, onClose, onSave }) {
birth_date: formData.birth_date || null, birth_date: formData.birth_date || null,
color: formData.color || null, color: formData.color || null,
microchip: formData.microchip || null, microchip: formData.microchip || null,
notes: formData.notes || null notes: formData.notes || null,
} }
if (dog) { if (dog) {
// Update existing dog
await axios.put(`/api/dogs/${dog.id}`, submitData) await axios.put(`/api/dogs/${dog.id}`, submitData)
} else { } else {
// Create new dog
await axios.post('/api/dogs', submitData) await axios.post('/api/dogs', submitData)
} }
onSave() onSave()
onClose() onClose()
} catch (error) { } catch (err) {
setError(error.response?.data?.error || 'Failed to save dog') setError(err.response?.data?.error || 'Failed to save dog')
setLoading(false) setLoading(false)
} }
} }
const males = dogs.filter(d => d.sex === 'male' && d.id !== dog?.id) const males = dogs.filter(d => d.sex === 'male' && d.id !== dog?.id)
const females = dogs.filter(d => d.sex === 'female' && d.id !== dog?.id) const females = dogs.filter(d => d.sex === 'female' && d.id !== dog?.id)
return ( return (
@@ -140,9 +134,7 @@ function DogForm({ dog, onClose, onSave }) {
<div className="modal-content" onClick={(e) => e.stopPropagation()}> <div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header"> <div className="modal-header">
<h2>{dog ? 'Edit Dog' : 'Add New Dog'}</h2> <h2>{dog ? 'Edit Dog' : 'Add New Dog'}</h2>
<button className="btn-icon" onClick={onClose}> <button className="btn-icon" onClick={onClose}><X size={24} /></button>
<X size={24} />
</button>
</div> </div>
<form onSubmit={handleSubmit} className="modal-body"> <form onSubmit={handleSubmit} className="modal-body">
@@ -151,48 +143,25 @@ function DogForm({ dog, onClose, onSave }) {
<div className="form-grid"> <div className="form-grid">
<div className="form-group"> <div className="form-group">
<label className="label">Name *</label> <label className="label">Name *</label>
<input <input type="text" name="name" className="input"
type="text" value={formData.name} onChange={handleChange} required />
name="name"
className="input"
value={formData.name}
onChange={handleChange}
required
/>
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="label">Registration Number</label> <label className="label">Registration Number</label>
<input <input type="text" name="registration_number" className="input"
type="text" value={formData.registration_number} onChange={handleChange} />
name="registration_number"
className="input"
value={formData.registration_number}
onChange={handleChange}
/>
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="label">Breed *</label> <label className="label">Breed *</label>
<input <input type="text" name="breed" className="input"
type="text" value={formData.breed} onChange={handleChange} required />
name="breed"
className="input"
value={formData.breed}
onChange={handleChange}
required
/>
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="label">Sex *</label> <label className="label">Sex *</label>
<select <select name="sex" className="input" value={formData.sex} onChange={handleChange} required>
name="sex"
className="input"
value={formData.sex}
onChange={handleChange}
required
>
<option value="male">Male</option> <option value="male">Male</option>
<option value="female">Female</option> <option value="female">Female</option>
</select> </select>
@@ -200,62 +169,77 @@ function DogForm({ dog, onClose, onSave }) {
<div className="form-group"> <div className="form-group">
<label className="label">Birth Date</label> <label className="label">Birth Date</label>
<input <input type="date" name="birth_date" className="input"
type="date" value={formData.birth_date} onChange={handleChange} />
name="birth_date"
className="input"
value={formData.birth_date}
onChange={handleChange}
/>
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="label">Color</label> <label className="label">Color</label>
<input <input type="text" name="color" className="input"
type="text" value={formData.color} onChange={handleChange} />
name="color"
className="input"
value={formData.color}
onChange={handleChange}
/>
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="label">Microchip Number</label> <label className="label">Microchip Number</label>
<input <input type="text" name="microchip" className="input"
type="text" value={formData.microchip} onChange={handleChange} />
name="microchip"
className="input"
value={formData.microchip}
onChange={handleChange}
/>
</div> </div>
</div> </div>
{/* Litter or Manual Parent Selection */} {/* Champion Toggle */}
<div style={{ marginTop: '1.5rem', padding: '1rem', background: 'rgba(99, 102, 241, 0.05)', borderRadius: '8px', border: '1px solid rgba(99, 102, 241, 0.2)' }}> <div style={{
marginTop: '1.25rem',
padding: '0.875rem 1rem',
background: formData.is_champion ? 'rgba(194, 134, 42, 0.08)' : 'var(--bg-primary)',
border: formData.is_champion ? '1px solid var(--champion-gold)' : '1px solid var(--border)',
borderRadius: 'var(--radius)',
transition: 'all 0.2s',
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
cursor: 'pointer',
}}
onClick={() => setFormData(prev => ({ ...prev, is_champion: !prev.is_champion }))}
>
<input
type="checkbox"
name="is_champion"
id="is_champion"
checked={!!formData.is_champion}
onChange={handleChange}
style={{ width: '18px', height: '18px', cursor: 'pointer', accentColor: 'var(--champion-gold)' }}
onClick={e => e.stopPropagation()}
/>
<Award size={18} style={{ color: formData.is_champion ? 'var(--champion-gold)' : 'var(--text-muted)' }} />
<div>
<div style={{ fontWeight: 600, color: formData.is_champion ? 'var(--champion-gold)' : 'var(--text-primary)', fontSize: '0.9375rem' }}>
Champion
</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
Mark this dog as a titled champion &mdash; offspring will display a Champion Bloodline badge
</div>
</div>
</div>
{/* Parent Section */}
<div style={{
marginTop: '1.5rem', padding: '1rem',
background: 'rgba(194, 134, 42, 0.04)',
borderRadius: '8px',
border: '1px solid rgba(194, 134, 42, 0.15)'
}}>
<label className="label" style={{ marginBottom: '0.75rem', display: 'block', fontWeight: '600' }}>Parent Information</label> <label className="label" style={{ marginBottom: '0.75rem', display: 'block', fontWeight: '600' }}>Parent Information</label>
{littersAvailable && ( {littersAvailable && (
<div style={{ display: 'flex', gap: '1.5rem', marginBottom: '1rem', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: '1.5rem', marginBottom: '1rem', flexWrap: 'wrap' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', fontSize: '0.95rem' }}> <label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', fontSize: '0.95rem' }}>
<input <input type="radio" name="parentMode" checked={!useManualParents}
type="radio" onChange={() => setUseManualParents(false)} style={{ width: '16px', height: '16px' }} />
name="parentMode"
checked={!useManualParents}
onChange={() => setUseManualParents(false)}
style={{ width: '16px', height: '16px' }}
/>
<span>Link to Litter</span> <span>Link to Litter</span>
</label> </label>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', fontSize: '0.95rem' }}> <label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', fontSize: '0.95rem' }}>
<input <input type="radio" name="parentMode" checked={useManualParents}
type="radio" onChange={() => setUseManualParents(true)} style={{ width: '16px', height: '16px' }} />
name="parentMode"
checked={useManualParents}
onChange={() => setUseManualParents(true)}
style={{ width: '16px', height: '16px' }}
/>
<span>Manual Parent Selection</span> <span>Manual Parent Selection</span>
</label> </label>
</div> </div>
@@ -264,12 +248,8 @@ function DogForm({ dog, onClose, onSave }) {
{!useManualParents && littersAvailable ? ( {!useManualParents && littersAvailable ? (
<div className="form-group" style={{ marginTop: '0.5rem' }}> <div className="form-group" style={{ marginTop: '0.5rem' }}>
<label className="label">Select Litter</label> <label className="label">Select Litter</label>
<select <select name="litter_id" className="input"
name="litter_id" value={formData.litter_id || ''} onChange={handleChange}>
className="input"
value={formData.litter_id || ''}
onChange={handleChange}
>
<option value="">No Litter</option> <option value="">No Litter</option>
{litters.map(l => ( {litters.map(l => (
<option key={l.id} value={l.id}> <option key={l.id} value={l.id}>
@@ -278,7 +258,7 @@ function DogForm({ dog, onClose, onSave }) {
))} ))}
</select> </select>
{formData.litter_id && ( {formData.litter_id && (
<div style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: '#6366f1', fontStyle: 'italic' }}> <div style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: 'var(--primary)', fontStyle: 'italic' }}>
Parents will be automatically set from the selected litter Parents will be automatically set from the selected litter
</div> </div>
)} )}
@@ -287,31 +267,18 @@ function DogForm({ dog, onClose, onSave }) {
<div className="form-grid" style={{ marginTop: '0.5rem' }}> <div className="form-grid" style={{ marginTop: '0.5rem' }}>
<div className="form-group"> <div className="form-group">
<label className="label">Sire (Father)</label> <label className="label">Sire (Father)</label>
<select <select name="sire_id" className="input"
name="sire_id" value={formData.sire_id || ''} onChange={handleChange}>
className="input"
value={formData.sire_id || ''}
onChange={handleChange}
>
<option value="">Unknown</option> <option value="">Unknown</option>
{males.map(d => ( {males.map(d => <option key={d.id} value={d.id}>{d.name}{d.is_champion ? ' ✪' : ''}</option>)}
<option key={d.id} value={d.id}>{d.name}</option>
))}
</select> </select>
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="label">Dam (Mother)</label> <label className="label">Dam (Mother)</label>
<select <select name="dam_id" className="input"
name="dam_id" value={formData.dam_id || ''} onChange={handleChange}>
className="input"
value={formData.dam_id || ''}
onChange={handleChange}
>
<option value="">Unknown</option> <option value="">Unknown</option>
{females.map(d => ( {females.map(d => <option key={d.id} value={d.id}>{d.name}{d.is_champion ? ' ✪' : ''}</option>)}
<option key={d.id} value={d.id}>{d.name}</option>
))}
</select> </select>
</div> </div>
</div> </div>
@@ -320,19 +287,12 @@ function DogForm({ dog, onClose, onSave }) {
<div className="form-group" style={{ marginTop: '1rem' }}> <div className="form-group" style={{ marginTop: '1rem' }}>
<label className="label">Notes</label> <label className="label">Notes</label>
<textarea <textarea name="notes" className="input" rows="4"
name="notes" value={formData.notes} onChange={handleChange} />
className="input"
rows="4"
value={formData.notes}
onChange={handleChange}
/>
</div> </div>
<div className="modal-footer"> <div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={onClose} disabled={loading}> <button type="button" className="btn btn-secondary" onClick={onClose} disabled={loading}>Cancel</button>
Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={loading}> <button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? 'Saving...' : dog ? 'Update Dog' : 'Add Dog'} {loading ? 'Saving...' : dog ? 'Update Dog' : 'Add Dog'}
</button> </button>

View File

@@ -0,0 +1,36 @@
import { createContext, useContext, useEffect, useState } from 'react'
import axios from 'axios'
const SettingsContext = createContext({})
export function SettingsProvider({ children }) {
const [settings, setSettings] = useState({
kennel_name: 'BREEDR',
kennel_tagline: '',
})
const [loading, setLoading] = useState(true)
useEffect(() => {
axios.get('/api/settings')
.then(res => {
setSettings(prev => ({ ...prev, ...res.data }))
})
.catch(() => {})
.finally(() => setLoading(false))
}, [])
const saveSettings = async (updates) => {
await axios.put('/api/settings', updates)
setSettings(prev => ({ ...prev, ...updates }))
}
return (
<SettingsContext.Provider value={{ settings, saveSettings, loading }}>
{children}
</SettingsContext.Provider>
)
}
export function useSettings() {
return useContext(SettingsContext)
}

View File

@@ -5,35 +5,45 @@
} }
:root { :root {
/* Modern dark color palette */ /* Primary accent: warm amber/copper to echo the gold-rust brand gradient */
--primary: #3b82f6; --primary: #c2862a;
--primary-hover: #2563eb; --primary-hover: #a86e1c;
--primary-light: #60a5fa; --primary-light: #e0a84a;
--accent: #8b5cf6;
--success: #10b981; /* Secondary/accent: deep copper-red for punch */
--accent: #9b3a10;
/* Status colors stay neutral/functional */
--success: #22c55e;
--danger: #ef4444; --danger: #ef4444;
--warning: #f59e0b; --warning: #f59e0b;
/* Dark theme */ /* Dark theme backgrounds — slightly warmer tones */
--bg-primary: #0f172a; --bg-primary: #0e0f0c;
--bg-secondary: #1e293b; --bg-secondary: #1a1a15;
--bg-tertiary: #334155; --bg-tertiary: #2a2820;
--bg-elevated: #1e293b; --bg-elevated: #222018;
/* Borders */ /* Borders — warm dark */
--border: #334155; --border: #38352a;
--border-light: #475569; --border-light: #524e3e;
/* Text */ /* Text */
--text-primary: #f1f5f9; --text-primary: #f5f0e8;
--text-secondary: #cbd5e1; --text-secondary: #ccc4b0;
--text-muted: #94a3b8; --text-muted: #8c8472;
/* Shadows */ /* Shadows */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3); --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.4);
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.4); --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.5);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5); --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.6);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.6); --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.7);
/* Champion badge colors */
--champion-gold: #d4a017;
--champion-glow: rgba(212, 160, 23, 0.25);
--bloodline-amber: #b06010;
--bloodline-glow: rgba(176, 96, 16, 0.2);
/* Misc */ /* Misc */
--radius: 0.5rem; --radius: 0.5rem;
@@ -130,14 +140,15 @@ h3 { font-size: 1.25rem; }
} }
.btn-primary { .btn-primary {
background: var(--primary); background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
color: white; color: var(--bg-primary);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
font-weight: 600;
} }
.btn-primary:hover:not(:disabled) { .btn-primary:hover:not(:disabled) {
background: var(--primary-hover); background: linear-gradient(135deg, var(--primary-light) 0%, var(--primary) 100%);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4); box-shadow: 0 4px 12px rgba(194, 134, 42, 0.4);
} }
.btn-secondary { .btn-secondary {
@@ -228,7 +239,7 @@ textarea:focus,
select:focus { select:focus {
outline: none; outline: none;
border-color: var(--primary); border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); box-shadow: 0 0 0 3px rgba(194, 134, 42, 0.15);
} }
.input::placeholder { .input::placeholder {
@@ -243,7 +254,7 @@ textarea {
select { select {
cursor: pointer; cursor: pointer;
appearance: none; appearance: none;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%2394a3b8' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%238c8472' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
background-position: right 0.5rem center; background-position: right 0.5rem center;
background-repeat: no-repeat; background-repeat: no-repeat;
background-size: 1.5em 1.5em; background-size: 1.5em 1.5em;
@@ -308,15 +319,50 @@ select {
} }
.badge-primary { .badge-primary {
background: rgba(59, 130, 246, 0.2); background: rgba(194, 134, 42, 0.2);
color: var(--primary-light); color: var(--primary-light);
} }
.badge-success { .badge-success {
background: rgba(16, 185, 129, 0.2); background: rgba(34, 197, 94, 0.2);
color: var(--success); color: var(--success);
} }
/* Champion Badges */
.badge-champion {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.2rem 0.55rem;
font-size: 0.7rem;
font-weight: 700;
border-radius: 9999px;
background: linear-gradient(135deg, rgba(212,160,23,0.25) 0%, rgba(155,58,16,0.2) 100%);
color: var(--champion-gold);
border: 1px solid rgba(212, 160, 23, 0.45);
box-shadow: 0 0 6px var(--champion-glow);
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: nowrap;
}
.badge-bloodline {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.2rem 0.55rem;
font-size: 0.7rem;
font-weight: 700;
border-radius: 9999px;
background: linear-gradient(135deg, rgba(176,96,16,0.2) 0%, rgba(139,37,0,0.15) 100%);
color: var(--bloodline-amber);
border: 1px solid rgba(176, 96, 16, 0.4);
box-shadow: 0 0 6px var(--bloodline-glow);
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: nowrap;
}
/* Modal */ /* Modal */
.modal-overlay { .modal-overlay {
position: fixed; position: fixed;
@@ -324,7 +370,7 @@ select {
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background: rgba(0, 0, 0, 0.75); background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
display: flex; display: flex;
align-items: center; align-items: center;
@@ -475,9 +521,9 @@ select {
} }
.risk-low { .risk-low {
background: rgba(16, 185, 129, 0.15); background: rgba(34, 197, 94, 0.15);
color: var(--success); color: var(--success);
border: 1px solid rgba(16, 185, 129, 0.3); border: 1px solid rgba(34, 197, 94, 0.3);
} }
.risk-med { .risk-med {

View File

@@ -1,10 +1,13 @@
import React from 'react' import { StrictMode } from 'react'
import ReactDOM from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { SettingsProvider } from './hooks/useSettings'
import App from './App.jsx' import App from './App.jsx'
import './index.css' import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render( createRoot(document.getElementById('root')).render(
<React.StrictMode> <StrictMode>
<App /> <SettingsProvider>
</React.StrictMode>, <App />
</SettingsProvider>
</StrictMode>,
) )

View File

@@ -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,7 +121,7 @@ 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>
@@ -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>
)} )}
@@ -313,7 +314,8 @@ function DogDetail() {
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,10 +341,7 @@ function DogDetail() {
<DogForm <DogForm
dog={dog} dog={dog}
onClose={() => setShowEditModal(false)} onClose={() => setShowEditModal(false)}
onSave={() => { onSave={() => { fetchDog(); setShowEditModal(false) }}
fetchDog()
setShowEditModal(false)
}}
/> />
)} )}
</div> </div>

View File

@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'
import { Dog, Plus, Search, Calendar, Hash, ArrowRight } from 'lucide-react' import { Dog, Plus, Search, Calendar, Hash, ArrowRight } 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 DogList() { function DogList() {
const [dogs, setDogs] = useState([]) const [dogs, setDogs] = useState([])
@@ -12,13 +13,8 @@ function DogList() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [showAddModal, setShowAddModal] = useState(false) const [showAddModal, setShowAddModal] = useState(false)
useEffect(() => { useEffect(() => { fetchDogs() }, [])
fetchDogs() useEffect(() => { filterDogs() }, [dogs, search, sexFilter])
}, [])
useEffect(() => {
filterDogs()
}, [dogs, search, sexFilter])
const fetchDogs = async () => { const fetchDogs = async () => {
try { try {
@@ -33,24 +29,19 @@ function DogList() {
const filterDogs = () => { const filterDogs = () => {
let filtered = dogs let filtered = dogs
if (search) { if (search) {
filtered = filtered.filter(dog => filtered = filtered.filter(dog =>
dog.name.toLowerCase().includes(search.toLowerCase()) || dog.name.toLowerCase().includes(search.toLowerCase()) ||
(dog.registration_number && dog.registration_number.toLowerCase().includes(search.toLowerCase())) (dog.registration_number && dog.registration_number.toLowerCase().includes(search.toLowerCase()))
) )
} }
if (sexFilter !== 'all') { if (sexFilter !== 'all') {
filtered = filtered.filter(dog => dog.sex === sexFilter) filtered = filtered.filter(dog => dog.sex === sexFilter)
} }
setFilteredDogs(filtered) setFilteredDogs(filtered)
} }
const handleSave = () => { const handleSave = () => { fetchDogs() }
fetchDogs()
}
const calculateAge = (birthDate) => { const calculateAge = (birthDate) => {
if (!birthDate) return null if (!birthDate) return null
@@ -58,17 +49,16 @@ function DogList() {
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}mo` if (years === 0) return `${months}mo`
if (months === 0) return `${years}y` if (months === 0) return `${years}y`
return `${years}y ${months}mo` return `${years}y ${months}mo`
} }
// A dog has champion blood if sire or dam is a champion
const hasChampionBlood = (dog) =>
(dog.sire && dog.sire.is_champion) || (dog.dam && dog.dam.is_champion)
if (loading) { if (loading) {
return <div className="container loading">Loading dogs...</div> return <div className="container loading">Loading dogs...</div>
} }
@@ -111,10 +101,7 @@ function DogList() {
{(search || sexFilter !== 'all') && ( {(search || sexFilter !== 'all') && (
<button <button
className="btn btn-ghost" className="btn btn-ghost"
onClick={() => { onClick={() => { setSearch(''); setSexFilter('all') }}
setSearch('')
setSexFilter('all')
}}
style={{ padding: '0.625rem 1rem', fontSize: '0.875rem' }} style={{ padding: '0.625rem 1rem', fontSize: '0.875rem' }}
> >
Clear Clear
@@ -169,65 +156,60 @@ function DogList() {
e.currentTarget.style.boxShadow = 'var(--shadow-sm)' e.currentTarget.style.boxShadow = 'var(--shadow-sm)'
}} }}
> >
{/* Avatar Photo */} {/* Avatar */}
<div style={{ <div style={{
width: '80px', width: '80px', height: '80px', flexShrink: 0,
height: '80px',
flexShrink: 0,
borderRadius: 'var(--radius)', borderRadius: 'var(--radius)',
background: 'var(--bg-primary)', background: 'var(--bg-primary)',
border: '2px solid var(--border)', border: dog.is_champion
display: 'flex', ? '2px solid var(--champion-gold)'
alignItems: 'center', : hasChampionBlood(dog)
justifyContent: 'center', ? '2px solid var(--bloodline-amber)'
overflow: 'hidden' : '2px solid var(--border)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
overflow: 'hidden',
boxShadow: dog.is_champion
? '0 0 8px var(--champion-glow)'
: hasChampionBlood(dog)
? '0 0 8px var(--bloodline-glow)'
: 'none'
}}> }}>
{dog.photo_urls && dog.photo_urls.length > 0 ? ( {dog.photo_urls && dog.photo_urls.length > 0 ? (
<img <img
src={dog.photo_urls[0]} src={dog.photo_urls[0]}
alt={dog.name} alt={dog.name}
style={{ style={{ width: '100%', height: '100%', objectFit: 'cover' }}
width: '100%',
height: '100%',
objectFit: 'cover'
}}
/> />
) : ( ) : (
<Dog size={32} style={{ color: 'var(--text-muted)', opacity: 0.5 }} /> <Dog size={32} style={{ color: 'var(--text-muted)', opacity: 0.5 }} />
)} )}
</div> </div>
{/* Info Section */} {/* Info */}
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<h3 style={{ <h3 style={{
fontSize: '1.125rem', fontSize: '1.125rem',
marginBottom: '0.375rem', marginBottom: '0.25rem',
overflow: 'hidden', display: 'flex', alignItems: 'center', gap: '0.5rem',
textOverflow: 'ellipsis', flexWrap: 'wrap'
whiteSpace: 'nowrap'
}}> }}>
{dog.name} <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<span style={{ {dog.name}
marginLeft: '0.5rem', </span>
fontSize: '1rem', <span style={{ color: dog.sex === 'male' ? 'var(--primary)' : '#ec4899', fontSize: '1rem' }}>
color: dog.sex === 'male' ? 'var(--primary)' : '#ec4899'
}}>
{dog.sex === 'male' ? '♂' : '♀'} {dog.sex === 'male' ? '♂' : '♀'}
</span> </span>
{dog.is_champion ? <ChampionBadge /> : hasChampionBlood(dog) ? <ChampionBloodlineBadge /> : null}
</h3> </h3>
<div style={{ <div style={{
display: 'flex', display: 'flex', flexWrap: 'wrap', gap: '0.75rem',
flexWrap: 'wrap', fontSize: '0.8125rem', color: 'var(--text-secondary)', marginBottom: '0.5rem'
gap: '0.75rem',
fontSize: '0.8125rem',
color: 'var(--text-secondary)',
marginBottom: '0.5rem'
}}> }}>
<span>{dog.breed}</span> <span>{dog.breed}</span>
{dog.birth_date && ( {dog.birth_date && (
<> <>
<span></span> <span>·</span>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}> <span style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<Calendar size={12} /> <Calendar size={12} />
{calculateAge(dog.birth_date)} {calculateAge(dog.birth_date)}
@@ -236,7 +218,7 @@ function DogList() {
)} )}
{dog.color && ( {dog.color && (
<> <>
<span></span> <span>·</span>
<span>{dog.color}</span> <span>{dog.color}</span>
</> </>
)} )}
@@ -244,15 +226,12 @@ function DogList() {
{dog.registration_number && ( {dog.registration_number && (
<div style={{ <div style={{
display: 'inline-flex', display: 'inline-flex', alignItems: 'center', gap: '0.25rem',
alignItems: 'center',
gap: '0.25rem',
padding: '0.25rem 0.5rem', padding: '0.25rem 0.5rem',
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)',
fontSize: '0.75rem', fontSize: '0.75rem', fontFamily: 'monospace',
fontFamily: 'monospace',
color: 'var(--text-muted)' color: 'var(--text-muted)'
}}> }}>
<Hash size={10} /> <Hash size={10} />
@@ -261,11 +240,7 @@ function DogList() {
)} )}
</div> </div>
{/* Arrow Indicator */} <div style={{ opacity: 0.5, transition: 'var(--transition)' }}>
<div style={{
opacity: 0.5,
transition: 'var(--transition)'
}}>
<ArrowRight size={20} color="var(--text-muted)" /> <ArrowRight size={20} color="var(--text-muted)" />
</div> </div>
</Link> </Link>

View File

@@ -0,0 +1,160 @@
import { useState, useEffect } from 'react'
import { Settings, Save, CheckCircle } from 'lucide-react'
import { useSettings } from '../hooks/useSettings'
const FIELDS = [
{ key: 'kennel_name', label: 'Kennel / App Name', placeholder: 'BREEDR', type: 'text', required: true },
{ key: 'kennel_tagline', label: 'Tagline', placeholder: 'Raising champions since...', type: 'text' },
{ key: 'kennel_address', label: 'Address', placeholder: '123 Main St, City, ST', type: 'text' },
{ key: 'kennel_phone', label: 'Phone', placeholder: '(555) 000-0000', type: 'tel' },
{ key: 'kennel_email', label: 'Email', placeholder: 'kennel@example.com', type: 'email'},
{ key: 'kennel_website', label: 'Website', placeholder: 'https://yourdomain.com', type: 'url' },
{ key: 'kennel_akc_id', label: 'AKC Kennel ID', placeholder: 'Optional', type: 'text' },
{ key: 'kennel_breed', label: 'Primary Breed', placeholder: 'e.g. Labrador Retriever', type: 'text' },
]
export default function SettingsPage() {
const { settings, saveSettings } = useSettings()
const [form, setForm] = useState({})
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState(null)
useEffect(() => {
setForm({
kennel_name: settings.kennel_name || '',
kennel_tagline: settings.kennel_tagline || '',
kennel_address: settings.kennel_address || '',
kennel_phone: settings.kennel_phone || '',
kennel_email: settings.kennel_email || '',
kennel_website: settings.kennel_website || '',
kennel_akc_id: settings.kennel_akc_id || '',
kennel_breed: settings.kennel_breed || '',
})
}, [settings])
const handleChange = (key, value) => {
setForm(prev => ({ ...prev, [key]: value }))
setSaved(false)
}
const handleSubmit = async (e) => {
e.preventDefault()
if (!form.kennel_name?.trim()) {
setError('Kennel name is required.')
return
}
setSaving(true)
setError(null)
try {
await saveSettings(form)
setSaved(true)
setTimeout(() => setSaved(false), 3000)
} catch (err) {
setError('Failed to save settings. Please try again.')
} finally {
setSaving(false)
}
}
return (
<div className="container" style={{ paddingTop: '2rem', paddingBottom: '3rem', maxWidth: '720px' }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.5rem' }}>
<div style={{
width: '2.5rem', height: '2.5rem',
borderRadius: 'var(--radius)',
background: 'linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
boxShadow: '0 4px 12px rgba(194,134,42,0.3)'
}}>
<Settings size={18} color="#0e0f0c" />
</div>
<div>
<h1 style={{ marginBottom: 0 }}>Settings</h1>
<p style={{ color: 'var(--text-secondary)', fontSize: '0.875rem' }}>
Kennel profile &amp; app configuration
</p>
</div>
</div>
<div className="divider" />
<form onSubmit={handleSubmit}>
<div className="card">
<h3 style={{ marginBottom: '1.5rem', color: 'var(--primary-light)' }}>Kennel Information</h3>
{error && <div className="error" style={{ marginBottom: '1rem' }}>{error}</div>}
<div className="form-grid">
{FIELDS.map(field => (
<div className="form-group" key={field.key}>
<label className="label">
{field.label}
{field.required && <span style={{ color: 'var(--danger)', marginLeft: '0.25rem' }}>*</span>}
</label>
<input
type={field.type || 'text'}
className="input"
placeholder={field.placeholder}
value={form[field.key] || ''}
onChange={e => handleChange(field.key, e.target.value)}
/>
</div>
))}
</div>
<div className="divider" />
{/* Preview */}
{form.kennel_name && (
<div style={{
marginBottom: '1.5rem',
padding: '1rem',
background: 'var(--bg-tertiary)',
borderRadius: 'var(--radius)',
border: '1px solid var(--border)'
}}>
<p className="label" style={{ marginBottom: '0.5rem' }}>Header Preview</p>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{
fontSize: '1.75rem',
fontWeight: 700,
letterSpacing: '-0.025em',
background: 'linear-gradient(135deg, #c9940a 0%, #b5620a 50%, #8b2500 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
}}>
{form.kennel_name}
</span>
{form.kennel_tagline && (
<span style={{ color: 'var(--text-muted)', fontSize: '0.8rem', fontStyle: 'italic' }}>
{form.kennel_tagline}
</span>
)}
</div>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', alignItems: 'center' }}>
{saved && (
<span style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', color: 'var(--success)', fontSize: '0.875rem' }}>
<CheckCircle size={16} /> Saved!
</span>
)}
<button
type="submit"
className="btn btn-primary"
disabled={saving}
>
<Save size={16} />
{saving ? 'Saving...' : 'Save Settings'}
</button>
</div>
</div>
</form>
</div>
)
}

View File

@@ -2,174 +2,155 @@ const Database = require('better-sqlite3');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
function initDatabase(dbPath) { const dbPath = path.join(__dirname, '../../data');
// Ensure data directory exists const db = new Database(path.join(dbPath, 'breedr.db'));
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const db = new Database(dbPath);
// Enable foreign keys
db.pragma('foreign_keys = ON');
console.log('Initializing database schema...');
// Dogs table - NO sire/dam columns, only litter_id
db.exec(`
CREATE TABLE IF NOT EXISTS dogs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
registration_number TEXT UNIQUE,
breed TEXT NOT NULL,
sex TEXT NOT NULL CHECK(sex IN ('male', 'female')),
birth_date DATE,
color TEXT,
microchip TEXT,
photo_urls TEXT, -- JSON array of photo URLs
notes TEXT,
litter_id INTEGER,
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (litter_id) REFERENCES litters(id) ON DELETE SET NULL
)
`);
// Create unique index for microchip that allows NULL values
db.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_dogs_microchip
ON dogs(microchip)
WHERE microchip IS NOT NULL
`);
// Parents table - Stores sire/dam relationships
db.exec(`
CREATE TABLE IF NOT EXISTS parents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dog_id INTEGER NOT NULL,
parent_id INTEGER NOT NULL,
parent_type TEXT NOT NULL CHECK(parent_type IN ('sire', 'dam')),
FOREIGN KEY (dog_id) REFERENCES dogs(id) ON DELETE CASCADE,
FOREIGN KEY (parent_id) REFERENCES dogs(id) ON DELETE CASCADE,
UNIQUE(dog_id, parent_type)
)
`);
// Litters table - Breeding records
db.exec(`
CREATE TABLE IF NOT EXISTS litters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sire_id INTEGER NOT NULL,
dam_id INTEGER NOT NULL,
breeding_date DATE NOT NULL,
whelping_date DATE,
puppy_count INTEGER DEFAULT 0,
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (sire_id) REFERENCES dogs(id) ON DELETE CASCADE,
FOREIGN KEY (dam_id) REFERENCES dogs(id) ON DELETE CASCADE
)
`);
// Health records table
db.exec(`
CREATE TABLE IF NOT EXISTS health_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dog_id INTEGER NOT NULL,
record_type TEXT NOT NULL CHECK(record_type IN ('test', 'vaccination', 'exam', 'treatment', 'certification')),
test_name TEXT,
test_date DATE NOT NULL,
result TEXT,
document_url TEXT,
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (dog_id) REFERENCES dogs(id) ON DELETE CASCADE
)
`);
// Heat cycles table
db.exec(`
CREATE TABLE IF NOT EXISTS heat_cycles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dog_id INTEGER NOT NULL,
start_date DATE NOT NULL,
end_date DATE,
progesterone_peak_date DATE,
breeding_date DATE,
breeding_successful INTEGER DEFAULT 0,
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (dog_id) REFERENCES dogs(id) ON DELETE CASCADE
)
`);
// Traits table - Genetic trait tracking
db.exec(`
CREATE TABLE IF NOT EXISTS traits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dog_id INTEGER NOT NULL,
trait_category TEXT NOT NULL,
trait_name TEXT NOT NULL,
trait_value TEXT NOT NULL,
inherited_from INTEGER,
notes TEXT,
FOREIGN KEY (dog_id) REFERENCES dogs(id) ON DELETE CASCADE,
FOREIGN KEY (inherited_from) REFERENCES dogs(id) ON DELETE SET NULL
)
`);
// Create indexes for performance
db.exec(`
CREATE INDEX IF NOT EXISTS idx_dogs_name ON dogs(name);
CREATE INDEX IF NOT EXISTS idx_dogs_registration ON dogs(registration_number);
CREATE INDEX IF NOT EXISTS idx_dogs_litter ON dogs(litter_id);
CREATE INDEX IF NOT EXISTS idx_parents_dog ON parents(dog_id);
CREATE INDEX IF NOT EXISTS idx_parents_parent ON parents(parent_id);
CREATE INDEX IF NOT EXISTS idx_litters_sire ON litters(sire_id);
CREATE INDEX IF NOT EXISTS idx_litters_dam ON litters(dam_id);
CREATE INDEX IF NOT EXISTS idx_health_dog ON health_records(dog_id);
CREATE INDEX IF NOT EXISTS idx_heat_dog ON heat_cycles(dog_id);
CREATE INDEX IF NOT EXISTS idx_traits_dog ON traits(dog_id);
`);
// Create trigger for updated_at
db.exec(`
CREATE TRIGGER IF NOT EXISTS update_dogs_timestamp
AFTER UPDATE ON dogs
FOR EACH ROW
BEGIN
UPDATE dogs SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END;
`);
console.log('✓ Database schema initialized successfully!');
console.log('✓ Dogs table: NO sire/dam columns, uses parents table');
console.log('✓ Parents table: Stores sire/dam relationships');
console.log('✓ Litters table: Links puppies via litter_id');
db.close();
return true;
}
function getDatabase() { function getDatabase() {
const dbPath = process.env.DB_PATH || path.join(__dirname, '../../data/breedr.db');
const db = new Database(dbPath);
db.pragma('foreign_keys = ON');
return db; return db;
} }
module.exports = { initDatabase, getDatabase }; function initDatabase() {
db.pragma('foreign_keys = ON');
db.pragma('journal_mode = WAL');
// Run initialization if called directly // ── Dogs ────────────────────────────────────────────────────────────
if (require.main === module) { db.exec(`
const dbPath = process.env.DB_PATH || path.join(__dirname, '../../data/breedr.db'); CREATE TABLE IF NOT EXISTS dogs (
console.log('\n=========================================='); id INTEGER PRIMARY KEY AUTOINCREMENT,
console.log('BREEDR Database Initialization'); name TEXT NOT NULL,
console.log('=========================================='); registration_number TEXT,
console.log(`Database: ${dbPath}`); breed TEXT NOT NULL,
console.log('==========================================\n'); sex TEXT NOT NULL CHECK(sex IN ('male', 'female')),
initDatabase(dbPath); birth_date TEXT,
console.log('\n✓ Database ready!\n'); color TEXT,
microchip TEXT,
litter_id INTEGER,
is_active INTEGER DEFAULT 1,
is_champion INTEGER DEFAULT 0,
photo_urls TEXT DEFAULT '[]',
notes TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)
`);
// migrate: add is_champion if missing (safe on existing DBs)
try {
db.exec(`ALTER TABLE dogs ADD COLUMN is_champion INTEGER DEFAULT 0`);
} catch (_) { /* column already exists */ }
// ── Parents ─────────────────────────────────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS parents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dog_id INTEGER NOT NULL,
parent_id INTEGER NOT NULL,
parent_type TEXT NOT NULL CHECK(parent_type IN ('sire', 'dam')),
FOREIGN KEY (dog_id) REFERENCES dogs(id),
FOREIGN KEY (parent_id) REFERENCES dogs(id)
)
`);
// ── Breeding Records ────────────────────────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS breeding_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sire_id INTEGER NOT NULL,
dam_id INTEGER NOT NULL,
breeding_date TEXT,
due_date TEXT,
conception_method TEXT CHECK(conception_method IN ('natural', 'ai', 'frozen', 'surgical')),
notes TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (sire_id) REFERENCES dogs(id),
FOREIGN KEY (dam_id) REFERENCES dogs(id)
)
`);
// ── Litters ─────────────────────────────────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS litters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
breeding_id INTEGER,
sire_id INTEGER NOT NULL,
dam_id INTEGER NOT NULL,
whelp_date TEXT,
total_count INTEGER DEFAULT 0,
male_count INTEGER DEFAULT 0,
female_count INTEGER DEFAULT 0,
stillborn_count INTEGER DEFAULT 0,
notes TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (breeding_id) REFERENCES breeding_records(id),
FOREIGN KEY (sire_id) REFERENCES dogs(id),
FOREIGN KEY (dam_id) REFERENCES dogs(id)
)
`);
// ── Health Records ──────────────────────────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS health_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dog_id INTEGER NOT NULL,
record_type TEXT NOT NULL,
date TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
vet_name TEXT,
notes TEXT,
result TEXT,
next_due TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (dog_id) REFERENCES dogs(id)
)
`);
// ── Settings ─────────────────────────────────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kennel_name TEXT DEFAULT 'BREEDR',
kennel_tagline TEXT,
kennel_address TEXT,
kennel_phone TEXT,
kennel_email TEXT,
kennel_website TEXT,
kennel_akc_id TEXT,
kennel_breed TEXT,
owner_name TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)
`);
// migrate: add new kennel columns if missing (safe on existing DBs)
const kennelCols = [
['kennel_name', "TEXT DEFAULT 'BREEDR'"],
['kennel_tagline', 'TEXT'],
['kennel_address', 'TEXT'],
['kennel_phone', 'TEXT'],
['kennel_email', 'TEXT'],
['kennel_website', 'TEXT'],
['kennel_akc_id', 'TEXT'],
['kennel_breed', 'TEXT'],
['owner_name', 'TEXT'],
];
for (const [col, def] of kennelCols) {
try {
db.exec(`ALTER TABLE settings ADD COLUMN ${col} ${def}`);
} catch (_) { /* already exists */ }
}
// Seed a default settings row if none exists
const existing = db.prepare('SELECT id FROM settings LIMIT 1').get();
if (!existing) {
db.prepare(`INSERT INTO settings (kennel_name) VALUES (?)`).run('BREEDR');
}
console.log('✓ Database initialized successfully');
} }
module.exports = { getDatabase, initDatabase };

View File

@@ -1,96 +1 @@
const express = require('express'); Y29uc3QgZXhwcmVzcyA9IHJlcXVpcmUoJ2V4cHJlc3MnKTsKY29uc3QgY29ycyA9IHJlcXVpcmUoJ2NvcnMnKTsKY29uc3QgaGVsbWV0ID0gcmVxdWlyZSgnaGVsbWV0Jyk7CmNvbnN0IHBhdGggPSByZXF1aXJlKCdwYXRoJyk7CmNvbnN0IGZzID0gcmVxdWlyZSgnZnMnKTsKY29uc3QgeyBpbml0RGF0YWJhc2UgfSA9IHJlcXVpcmUoJy4vZGIvaW5pdCcpOwoKY29uc3QgYXBwID0gZXhwcmVzcygpOwpjb25zdCBQT1JUID0gcHJvY2Vzcy5lbnYuUE9SVCB8fCAzMDAwOwpjb25zdCBEQl9QQVRIID0gcHJvY2Vzcy5lbnYuREJfUEFUSCB8fCBwYXRoLmpvaW4oX19kaXJuYW1lLCAnLi4vZGF0YS9icmVlZHIuZGInKTsKY29uc3QgVVBMT0FEX1BBVEggPSBwcm9jZXNzLmVudi5VUExPQURfUEFUSCB8fCBwYXRoLmpvaW4oX19kaXJuYW1lLCAnLi4vdXBsb2FkcycpOwpjb25zdCBTVEFUSUNfUEFUSCA9IHByb2Nlc3MuZW52LlNUQVRJQ19QQVRIIHx8IHBhdGguam9pbihfX2Rpcm5hbWUsICcuLi9zdGF0aWMnKTsKCmNvbnN0IGRhdGFEaXIgPSBwYXRoLmRpcm5hbWUoREJfUEFUSCk7CmlmICghZnMuZXhpc3RzU3luYyhkYXRhRGlyKSkgZnMubWtkaXJTeW5jKGRhdGFEaXIsIHsgcmVjdXJzaXZlOiB0cnVlIH0pOwppZiAoIWZzLmV4aXN0c1N5bmMoVVBMT0FEX1BBVEgpKSBmcy5ta2RpclN5bmMoVVBMT0FEX1BBVEgsIHsgcmVjdXJzaXZlOiB0cnVlIH0pOwppZiAoIWZzLmV4aXN0c1N5bmMoU1RBVElDX1BBVEgpKSBmcy5ta2RpclN5bmMoU1RBVElDX1BBVEgsIHsgcmVjdXJzaXZlOiB0cnVlIH0pOwoKY29uc29sZS5sb2coJ0luaXRpYWxpemluZyBkYXRhYmFzZS4uLicpOwppbml0RGF0YWJhc2UoREJfUEFUSCk7CmNvbnNvbGUubG9nKCfinJMgRGF0YWJhc2UgcmVhZHkhXG4nKTsKCmFwcC51c2UoaGVsbWV0KHsgY29udGVudFNlY3VyaXR5UG9saWN5OiBmYWxzZSB9KSk7CmFwcC51c2UoY29ycygpKTsKYXBwLnVzZShleHByZXNzLmpzb24oKSk7CmFwcC51c2UoZXhwcmVzcy51cmxlbmNvZGVkKHsgZXh0ZW5kZWQ6IHRydWUgfSkpOwoKYXBwLnVzZSgnL3VwbG9hZHMnLCBleHByZXNzLnN0YXRpYyhVUExPQURfUEFUSCkpOwphcHAudXNlKCcvc3RhdGljJywgZXhwcmVzcy5zdGF0aWMoU1RBVElDX1BBVEgpKTsKYXBwLnVzZSgnL3VwbG9hZHMnLCAocmVxLCByZXMpID0+IHJlcy5zdGF0dXMoNDA0KS5qc29uKHsgZXJyb3I6ICdVcGxvYWQgbm90IGZvdW5kJyB9KSk7CmFwcC51c2UoJy9zdGF0aWMnLCAocmVxLCByZXMpID0+IHJlcy5zdGF0dXMoNDA0KS5qc29uKHsgZXJyb3I6ICdTdGF0aWMgYXNzZXQgbm90IGZvdW5kJyB9KSk7CgphcHAudXNlKCcvYXBpL2RvZ3MnLCByZXF1aXJlKCcuL3JvdXRlcy9kb2dzJykpOwphcHAudXNlKCcvYXBpL2xpdHRlcnMnLCByZXF1aXJlKCcuL3JvdXRlcy9saXR0ZXJzJykpOwphcHAudXNlKCcvYXBpL2hlYWx0aCcsIHJlcXVpcmUoJy4vcm91dGVzL2hlYWx0aCcpKTsKYXBwLnVzZSgnL2FwaS9wZWRpZ3JlZScsIHJlcXVpcmUoJy4vcm91dGVzL3BlZGlncmVlJykpOwphcHAudXNlKCcvYXBpL2JyZWVkaW5nJywgcmVxdWlyZSgnLi9yb3V0ZXMvYnJlZWRpbmcnKSk7CmFwcC51c2UoJy9hcGkvc2V0dGluZ3MnLCByZXF1aXJlKCcuL3JvdXRlcy9zZXR0aW5ncycpKTsKCmFwcC5nZXQoJy9hcGkvaGVhbHRoJywgKHJlcSwgcmVzKSA9PiB7CiAgcmVzLmpzb24oeyBzdGF0dXM6ICdvaycsIHRpbWVzdGFtcDogbmV3IERhdGUoKS50b0lTT1N0cmluZygpIH0pOwp9KTsKCmlmIChwcm9jZXNzLmVudi5OT0RFX0VOViA9PT0gJ3Byb2R1Y3Rpb24nKSB7CiAgY29uc3QgY2xpZW50QnVpbGRQYXRoID0gcGF0aC5qb2luKF9fZGlybmFtZSwgJy4uL2NsaWVudC9kaXN0Jyk7CiAgYXBwLnVzZShleHByZXNzLnN0YXRpYyhjbGllbnRCdWlsZFBhdGgpKTsKICBhcHAuZ2V0KC9eKD8hXC8oYXBpfHN0YXRpY3x1cGxvYWRzKVwvKS4qJC8sIChyZXEsIHJlcykgPT4gewogICAgcmVzLnNlbmRGaWxlKHBhdGguam9pbihjbGllbnRCdWlsZFBhdGgsICdpbmRleC5odG1sJykpOwogIH0pOwp9CgphcHAudXNlKChlcnIsIHJlcSwgcmVzLCBuZXh0KSA9PiB7CiAgY29uc29sZS5lcnJvcignRXJyb3I6JywgZXJyKTsKICByZXMuc3RhdHVzKGVyci5zdGF0dXMgfHwgNTAwKS5qc29uKHsKICAgIGVycm9yOiBlcnIubWVzc2FnZSB8fCAnSW50ZXJuYWwgc2VydmVyIGVycm9yJywKICAgIC4uLihwcm9jZXNzLmVudi5OT0RFX0VOViA9PT0gJ2RldmVsb3BtZW50JyAmJiB7IHN0YWNrOiBlcnIuc3RhY2sgfSkKICB9KTsKfSk7CgphcHAubGlzdGVuKFBPUlQsICcwLjAuMC4wJywgKCkgPT4gewogIGNvbnNvbGUubG9nKGBcbvCfkJUgQlJFRURSIFNlcnZlciBSdW5uaW5nYCk7CiAgY29uc29sZS5sb2coYD09PT09PT09PT09PT09PT09PT09PT09PT09PT09PWApOwogIGNvbnNvbGUubG9nKGBFbnZpcm9ubWVudDogJHtwcm9jZXNzLmVudi5OT0RFX0VOViB8fCAnZGV2ZWxvcG1lbnQnfWApOwogIGNvbnNvbGUubG9nKGBQb3J0OiAke1BPUlR9YCk7CiAgY29uc29sZS5sb2coYERhdGFiYXNlOiAke0RCX1BBVEh9YCk7CiAgY29uc29sZS5sb2coYFVwbG9hZHM6ICR7VVBMT0FEX1BBVEh9YCk7CiAgY29uc29sZS5sb2coYFN0YXRpYzogJHtTVEFUSUNfUEFUSH1gKTsKICBjb25zb2xlLmxvZyhgQWNjZXNzOiBodHRwOi8vbG9jYWxob3N0OiR7UE9SVH1gKTsKICBjb25zb2xlLmxvZyhgPT09PT09PT09PT09PT09PT09PT09PT09PT09PT09XG5gKTsKfSk7Cgptb2R1bGUuZXhwb3J0cyA9IGFwcDsK
const cors = require('cors');
const helmet = require('helmet');
const path = require('path');
const fs = require('fs');
const { initDatabase } = require('./db/init');
const app = express();
const PORT = process.env.PORT || 3000;
const DB_PATH = process.env.DB_PATH || path.join(__dirname, '../data/breedr.db');
const UPLOAD_PATH = process.env.UPLOAD_PATH || path.join(__dirname, '../uploads');
const STATIC_PATH = process.env.STATIC_PATH || path.join(__dirname, '../static');
// Ensure directories exist
const dataDir = path.dirname(DB_PATH);
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
if (!fs.existsSync(UPLOAD_PATH)) {
fs.mkdirSync(UPLOAD_PATH, { recursive: true });
}
if (!fs.existsSync(STATIC_PATH)) {
fs.mkdirSync(STATIC_PATH, { recursive: true });
}
// Initialize database schema (creates tables if they don't exist)
console.log('Initializing database...');
initDatabase(DB_PATH);
console.log('✓ Database ready!\n');
// Middleware
app.use(helmet({
contentSecurityPolicy: false, // Allow inline scripts for React
}));
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Static asset routes — registered BEFORE React catch-all so they are
// resolved directly and never fall through to index.html
app.use('/uploads', express.static(UPLOAD_PATH));
app.use('/static', express.static(STATIC_PATH));
// Explicit 404 for missing asset files so the catch-all never intercepts them
app.use('/uploads', (req, res) => res.status(404).json({ error: 'Upload not found' }));
app.use('/static', (req, res) => res.status(404).json({ error: 'Static asset not found' }));
// API Routes
app.use('/api/dogs', require('./routes/dogs'));
app.use('/api/litters', require('./routes/litters'));
app.use('/api/health', require('./routes/health'));
app.use('/api/pedigree', require('./routes/pedigree'));
app.use('/api/breeding', require('./routes/breeding'));
// Health check endpoint
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Serve React frontend in production
// The catch-all is intentionally placed AFTER all asset/API routes above.
// express.static(clientBuildPath) handles real build assets (JS/CSS chunks).
// The scoped '*' only fires for HTML5 client-side routes (e.g. /dogs, /litters).
if (process.env.NODE_ENV === 'production') {
const clientBuildPath = path.join(__dirname, '../client/dist');
app.use(express.static(clientBuildPath));
// Only send index.html for non-asset, non-api paths
app.get(/^(?!\/(api|static|uploads)\/).*$/, (req, res) => {
res.sendFile(path.join(clientBuildPath, 'index.html'));
});
}
// Error handling middleware
app.use((err, req, res, next) => {
console.error('Error:', err);
res.status(err.status || 500).json({
error: err.message || 'Internal server error',
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
});
});
// Start server
app.listen(PORT, '0.0.0.0', () => {
console.log(`\n🐕 BREEDR Server Running`);
console.log(`==============================`);
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
console.log(`Port: ${PORT}`);
console.log(`Database: ${DB_PATH}`);
console.log(`Uploads: ${UPLOAD_PATH}`);
console.log(`Static: ${STATIC_PATH}`);
console.log(`Access: http://localhost:${PORT}`);
console.log(`==============================\n`);
});
module.exports = app;

View File

@@ -5,7 +5,6 @@ const multer = require('multer');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
// Configure multer for photo uploads
const storage = multer.diskStorage({ const storage = multer.diskStorage({
destination: (req, file, cb) => { destination: (req, file, cb) => {
const uploadPath = process.env.UPLOAD_PATH || path.join(__dirname, '../../uploads'); const uploadPath = process.env.UPLOAD_PATH || path.join(__dirname, '../../uploads');
@@ -19,12 +18,10 @@ const storage = multer.diskStorage({
const upload = multer({ const upload = multer({
storage, storage,
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB limit limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (req, file, cb) => { fileFilter: (req, file, cb) => {
const allowedTypes = /jpeg|jpg|png|gif|webp/; const allowed = /jpeg|jpg|png|gif|webp/;
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase()); if (allowed.test(path.extname(file.originalname).toLowerCase()) && allowed.test(file.mimetype)) {
const mimetype = allowedTypes.test(file.mimetype);
if (extname && mimetype) {
cb(null, true); cb(null, true);
} else { } else {
cb(new Error('Only image files are allowed')); cb(new Error('Only image files are allowed'));
@@ -32,27 +29,39 @@ const upload = multer({
} }
}); });
// Helper function to convert empty strings to null const emptyToNull = (v) => (v === '' || v === undefined) ? null : v;
const emptyToNull = (value) => {
return (value === '' || value === undefined) ? null : value;
};
// GET all dogs // ── Shared SELECT columns ─────────────────────────────────────────────
const DOG_COLS = `
id, name, registration_number, breed, sex, birth_date,
color, microchip, photo_urls, notes, litter_id, is_active,
is_champion, created_at, updated_at
`;
// ── GET all dogs ───────────────────────────────────────────────────
router.get('/', (req, res) => { router.get('/', (req, res) => {
try { try {
const db = getDatabase(); const db = getDatabase();
const dogs = db.prepare(` const dogs = db.prepare(`
SELECT id, name, registration_number, breed, sex, birth_date, SELECT ${DOG_COLS}
color, microchip, photo_urls, notes, litter_id, is_active,
created_at, updated_at
FROM dogs FROM dogs
WHERE is_active = 1 WHERE is_active = 1
ORDER BY name ORDER BY name
`).all(); `).all();
// Parse photo_urls JSON // Also pull sire/dam so list page can compute bloodline status
const parentStmt = db.prepare(`
SELECT p.parent_type, d.id, d.name, d.is_champion
FROM parents p
JOIN dogs d ON p.parent_id = d.id
WHERE p.dog_id = ?
`);
dogs.forEach(dog => { dogs.forEach(dog => {
dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : []; dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
const parents = parentStmt.all(dog.id);
dog.sire = parents.find(p => p.parent_type === 'sire') || null;
dog.dam = parents.find(p => p.parent_type === 'dam') || null;
}); });
res.json(dogs); res.json(dogs);
@@ -62,38 +71,31 @@ router.get('/', (req, res) => {
} }
}); });
// GET single dog by ID with parents and offspring // ── GET single dog (with parents + offspring) ───────────────────────
router.get('/:id', (req, res) => { router.get('/:id', (req, res) => {
try { try {
const db = getDatabase(); const db = getDatabase();
const dog = db.prepare(` const dog = db.prepare(`SELECT ${DOG_COLS} FROM dogs WHERE id = ?`).get(req.params.id);
SELECT id, name, registration_number, breed, sex, birth_date,
color, microchip, photo_urls, notes, litter_id, is_active,
created_at, updated_at
FROM dogs
WHERE id = ?
`).get(req.params.id);
if (!dog) { if (!dog) return res.status(404).json({ error: 'Dog not found' });
return res.status(404).json({ error: 'Dog not found' });
}
dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : []; dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
// Get parents from parents table // Parents — include is_champion so frontend can render bloodline badge
const parents = db.prepare(` const parents = db.prepare(`
SELECT p.parent_type, d.* SELECT p.parent_type, d.id, d.name, d.is_champion
FROM parents p FROM parents p
JOIN dogs d ON p.parent_id = d.id JOIN dogs d ON p.parent_id = d.id
WHERE p.dog_id = ? WHERE p.dog_id = ?
`).all(req.params.id); `).all(req.params.id);
dog.sire = parents.find(p => p.parent_type === 'sire') || null; dog.sire = parents.find(p => p.parent_type === 'sire') || null;
dog.dam = parents.find(p => p.parent_type === 'dam') || null; dog.dam = parents.find(p => p.parent_type === 'dam') || null;
// Get offspring // Offspring — include is_champion for badge on offspring cards
dog.offspring = db.prepare(` dog.offspring = db.prepare(`
SELECT d.* FROM dogs d SELECT d.id, d.name, d.sex, d.is_champion
FROM dogs d
JOIN parents p ON d.id = p.dog_id JOIN parents p ON d.id = p.dog_id
WHERE p.parent_id = ? AND d.is_active = 1 WHERE p.parent_id = ? AND d.is_active = 1
`).all(req.params.id); `).all(req.params.id);
@@ -105,66 +107,50 @@ router.get('/:id', (req, res) => {
} }
}); });
// POST create new dog // ── POST create dog ────────────────────────────────────────────────
router.post('/', (req, res) => { router.post('/', (req, res) => {
try { try {
const { name, registration_number, breed, sex, birth_date, color, microchip, notes, sire_id, dam_id, litter_id } = req.body; const { name, registration_number, breed, sex, birth_date, color,
microchip, notes, sire_id, dam_id, litter_id, is_champion } = req.body;
console.log('Creating dog with data:', { name, breed, sex, sire_id, dam_id, litter_id }); console.log('Creating dog:', { name, breed, sex, sire_id, dam_id, litter_id, is_champion });
if (!name || !breed || !sex) { if (!name || !breed || !sex) {
return res.status(400).json({ error: 'Name, breed, and sex are required' }); return res.status(400).json({ error: 'Name, breed, and sex are required' });
} }
const db = getDatabase(); const db = getDatabase();
// Insert dog (dogs table has NO sire/dam columns)
const result = db.prepare(` const result = db.prepare(`
INSERT INTO dogs (name, registration_number, breed, sex, birth_date, color, microchip, notes, litter_id, photo_urls) INSERT INTO dogs (name, registration_number, breed, sex, birth_date, color,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) microchip, notes, litter_id, photo_urls, is_champion)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
name, name,
emptyToNull(registration_number), emptyToNull(registration_number),
breed, breed, sex,
sex,
emptyToNull(birth_date), emptyToNull(birth_date),
emptyToNull(color), emptyToNull(color),
emptyToNull(microchip), emptyToNull(microchip),
emptyToNull(notes), emptyToNull(notes),
emptyToNull(litter_id), emptyToNull(litter_id),
'[]' '[]',
is_champion ? 1 : 0
); );
const dogId = result.lastInsertRowid; const dogId = result.lastInsertRowid;
console.log(`✓ Dog inserted with ID: ${dogId}`); console.log(`✓ Dog inserted with ID: ${dogId}`);
// Add sire relationship if provided
if (sire_id && sire_id !== '' && sire_id !== null) { if (sire_id && sire_id !== '' && sire_id !== null) {
console.log(` Adding sire relationship: dog ${dogId} -> sire ${sire_id}`); db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(dogId, sire_id, 'sire');
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').
run(dogId, sire_id, 'sire');
console.log(` ✓ Sire relationship added`);
} }
// Add dam relationship if provided
if (dam_id && dam_id !== '' && dam_id !== null) { if (dam_id && dam_id !== '' && dam_id !== null) {
console.log(` Adding dam relationship: dog ${dogId} -> dam ${dam_id}`); db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(dogId, dam_id, 'dam');
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').
run(dogId, dam_id, 'dam');
console.log(` ✓ Dam relationship added`);
} }
// Fetch the created dog const dog = db.prepare(`SELECT ${DOG_COLS} FROM dogs WHERE id = ?`).get(dogId);
const dog = db.prepare(`
SELECT id, name, registration_number, breed, sex, birth_date,
color, microchip, photo_urls, notes, litter_id, is_active,
created_at, updated_at
FROM dogs
WHERE id = ?
`).get(dogId);
dog.photo_urls = []; dog.photo_urls = [];
console.log(`✓ Dog created successfully: ${dog.name} (ID: ${dogId})`); console.log(`✓ Dog created: ${dog.name} (ID: ${dogId})`);
res.status(201).json(dog); res.status(201).json(dog);
} catch (error) { } catch (error) {
console.error('Error creating dog:', error); console.error('Error creating dog:', error);
@@ -172,66 +158,47 @@ router.post('/', (req, res) => {
} }
}); });
// PUT update dog // ── PUT update dog ────────────────────────────────────────────────
router.put('/:id', (req, res) => { router.put('/:id', (req, res) => {
try { try {
const { name, registration_number, breed, sex, birth_date, color, microchip, notes, sire_id, dam_id, litter_id } = req.body; const { name, registration_number, breed, sex, birth_date, color,
microchip, notes, sire_id, dam_id, litter_id, is_champion } = req.body;
console.log(`Updating dog ${req.params.id} with data:`, { name, breed, sex, sire_id, dam_id, litter_id }); console.log(`Updating dog ${req.params.id}:`, { name, breed, sex, sire_id, dam_id, is_champion });
const db = getDatabase(); const db = getDatabase();
// Update dog record (dogs table has NO sire/dam columns)
db.prepare(` db.prepare(`
UPDATE dogs UPDATE dogs
SET name = ?, registration_number = ?, breed = ?, sex = ?, SET name = ?, registration_number = ?, breed = ?, sex = ?,
birth_date = ?, color = ?, microchip = ?, notes = ?, litter_id = ? birth_date = ?, color = ?, microchip = ?, notes = ?,
litter_id = ?, is_champion = ?, updated_at = datetime('now')
WHERE id = ? WHERE id = ?
`).run( `).run(
name, name,
emptyToNull(registration_number), emptyToNull(registration_number),
breed, breed, sex,
sex,
emptyToNull(birth_date), emptyToNull(birth_date),
emptyToNull(color), emptyToNull(color),
emptyToNull(microchip), emptyToNull(microchip),
emptyToNull(notes), emptyToNull(notes),
emptyToNull(litter_id), emptyToNull(litter_id),
is_champion ? 1 : 0,
req.params.id req.params.id
); );
console.log(` ✓ Dog record updated`);
// Remove existing parent relationships // Re-link parents
db.prepare('DELETE FROM parents WHERE dog_id = ?').run(req.params.id); db.prepare('DELETE FROM parents WHERE dog_id = ?').run(req.params.id);
console.log(` ✓ Old parent relationships removed`);
// Add new sire relationship if provided
if (sire_id && sire_id !== '' && sire_id !== null) { if (sire_id && sire_id !== '' && sire_id !== null) {
console.log(` Adding sire relationship: dog ${req.params.id} -> sire ${sire_id}`); db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(req.params.id, sire_id, 'sire');
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').
run(req.params.id, sire_id, 'sire');
console.log(` ✓ Sire relationship added`);
} }
// Add new dam relationship if provided
if (dam_id && dam_id !== '' && dam_id !== null) { if (dam_id && dam_id !== '' && dam_id !== null) {
console.log(` Adding dam relationship: dog ${req.params.id} -> dam ${dam_id}`); db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(req.params.id, dam_id, 'dam');
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').
run(req.params.id, dam_id, 'dam');
console.log(` ✓ Dam relationship added`);
} }
// Fetch updated dog const dog = db.prepare(`SELECT ${DOG_COLS} FROM dogs WHERE id = ?`).get(req.params.id);
const dog = db.prepare(`
SELECT id, name, registration_number, breed, sex, birth_date,
color, microchip, photo_urls, notes, litter_id, is_active,
created_at, updated_at
FROM dogs
WHERE id = ?
`).get(req.params.id);
dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : []; dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
console.log(`✓ Dog updated successfully: ${dog.name} (ID: ${req.params.id})`); console.log(`✓ Dog updated: ${dog.name} (ID: ${req.params.id})`);
res.json(dog); res.json(dog);
} catch (error) { } catch (error) {
console.error('Error updating dog:', error); console.error('Error updating dog:', error);
@@ -239,7 +206,7 @@ router.put('/:id', (req, res) => {
} }
}); });
// DELETE dog (soft delete) // ── DELETE dog (soft) ───────────────────────────────────────────────
router.delete('/:id', (req, res) => { router.delete('/:id', (req, res) => {
try { try {
const db = getDatabase(); const db = getDatabase();
@@ -252,23 +219,17 @@ router.delete('/:id', (req, res) => {
} }
}); });
// POST upload photo for dog // ── POST upload photo ───────────────────────────────────────────────
router.post('/:id/photos', upload.single('photo'), (req, res) => { router.post('/:id/photos', upload.single('photo'), (req, res) => {
try { try {
if (!req.file) { if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
return res.status(400).json({ error: 'No file uploaded' });
}
const db = getDatabase(); const db = getDatabase();
const dog = db.prepare('SELECT photo_urls FROM dogs WHERE id = ?').get(req.params.id); const dog = db.prepare('SELECT photo_urls FROM dogs WHERE id = ?').get(req.params.id);
if (!dog) return res.status(404).json({ error: 'Dog not found' });
if (!dog) {
return res.status(404).json({ error: 'Dog not found' });
}
const photoUrls = dog.photo_urls ? JSON.parse(dog.photo_urls) : []; const photoUrls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
photoUrls.push(`/uploads/${req.file.filename}`); photoUrls.push(`/uploads/${req.file.filename}`);
db.prepare('UPDATE dogs SET photo_urls = ? WHERE id = ?').run(JSON.stringify(photoUrls), req.params.id); db.prepare('UPDATE dogs SET photo_urls = ? WHERE id = ?').run(JSON.stringify(photoUrls), req.params.id);
res.json({ url: `/uploads/${req.file.filename}`, photos: photoUrls }); res.json({ url: `/uploads/${req.file.filename}`, photos: photoUrls });
@@ -278,27 +239,22 @@ router.post('/:id/photos', upload.single('photo'), (req, res) => {
} }
}); });
// DELETE photo from dog // ── DELETE photo ─────────────────────────────────────────────────────
router.delete('/:id/photos/:photoIndex', (req, res) => { router.delete('/:id/photos/:photoIndex', (req, res) => {
try { try {
const db = getDatabase(); const db = getDatabase();
const dog = db.prepare('SELECT photo_urls FROM dogs WHERE id = ?').get(req.params.id); const dog = db.prepare('SELECT photo_urls FROM dogs WHERE id = ?').get(req.params.id);
if (!dog) return res.status(404).json({ error: 'Dog not found' });
if (!dog) { const photoUrls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
return res.status(404).json({ error: 'Dog not found' });
}
const photoUrls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
const photoIndex = parseInt(req.params.photoIndex); const photoIndex = parseInt(req.params.photoIndex);
if (photoIndex >= 0 && photoIndex < photoUrls.length) { if (photoIndex >= 0 && photoIndex < photoUrls.length) {
const photoPath = path.join(process.env.UPLOAD_PATH || path.join(__dirname, '../../uploads'), path.basename(photoUrls[photoIndex])); const photoPath = path.join(
process.env.UPLOAD_PATH || path.join(__dirname, '../../uploads'),
// Delete file from disk path.basename(photoUrls[photoIndex])
if (fs.existsSync(photoPath)) { );
fs.unlinkSync(photoPath); if (fs.existsSync(photoPath)) fs.unlinkSync(photoPath);
}
photoUrls.splice(photoIndex, 1); photoUrls.splice(photoIndex, 1);
db.prepare('UPDATE dogs SET photo_urls = ? WHERE id = ?').run(JSON.stringify(photoUrls), req.params.id); db.prepare('UPDATE dogs SET photo_urls = ? WHERE id = ?').run(JSON.stringify(photoUrls), req.params.id);
} }

View File

@@ -0,0 +1 @@
Y29uc3QgZXhwcmVzcyA9IHJlcXVpcmUoJ2V4cHJlc3MnKTsKY29uc3Qgcm91dGVyID0gZXhwcmVzcy5Sb3V0ZXIoKTsKY29uc3QgeyBnZXREYXRhYmFzZSB9ID0gcmVxdWlyZSgnLi4vZGIvaW5pdCcpOwoKLy8gR0VUIGFsbCBzZXR0aW5ncwpyb3V0ZXIuZ2V0KCcvJywgKHJlcSwgcmVzKSA9PiB7CiAgdHJ5IHsKICAgIGNvbnN0IGRiID0gZ2V0RGF0YWJhc2UoKTsKICAgIGNvbnN0IHJvd3MgPSBkYi5wcmVwYXJlKCdTRUxFQ1Qga2V5LCB2YWx1ZSBGUk9NIHNldHRpbmdzJykuYWxsKCk7CiAgICBjb25zdCBzZXR0aW5ncyA9IHt9OwogICAgcm93cy5mb3JFYWNoKHIgPT4geyBzZXR0aW5nc1tyLmtleV0gPSByLnZhbHVlOyB9KTsKICAgIHJlcy5qc29uKHNldHRpbmdzKTsKICB9IGNhdGNoIChlcnJvcikgewogICAgcmVzLnN0YXR1cyg1MDApLmpzb24oeyBlcnJvcjogZXJyb3IubWVzc2FnZSB9KTsKICB9Cn0pOwoKLy8gUFVUIHVwZGF0ZSBzZXR0aW5ncwpyb3V0ZXIucHV0KCcvJywgKHJlcSwgcmVzKSA9PiB7CiAgdHJ5IHsKICAgIGNvbnN0IGRiID0gZ2V0RGF0YWJhc2UoKTsKICAgIGNvbnN0IHVwc2VydCA9IGRiLnByZXBhcmUoJ0lOU0VSVCBJTlRPIHNldHRpbmdzIChrZXksIHZhbHVlKSBWQUxVRVMgKD8sID8pIE9OIENPTUZMSUNIVCBLRVBVUERBVEU9ZXhjbHVkZWQudmFsdWUnKTsKICAgIGNvbnN0IHVwZGF0ZU1hbnkgPSBkYi50cmFuc2FjdGlvbigoZW50cmllcykgPT4gewogICAgICBmb3IgKGNvbnN0IFtrZXksIHZhbHVlXSBvZiBPYmplY3QuZW50cmllcyhlbnRyaWVzKSkgewogICAgICAgIHVwc2VydC5ydW4oa2V5LCB2YWx1ZSA9PSBudWxsID8gJycgOiBTdHJpbmcodmFsdWUpKTsKICAgICAgfQogICAgfSk7CiAgICB1cGRhdGVNYW55KHJlcS5ib2R5KTsKICAgIHJlcy5qc29uKHsgbWVzc2FnZTogJ1NldHRpbmdzIHNhdmVkJyB9KTsKICB9IGNhdGNoIChlcnJvcikgewogICAgcmVzLnN0YXR1cyg1MDApLmpzb24oeyBlcnJvcjogZXJyb3IubWVzc2FnZSB9KTsKICB9Cn0pOwoKbW9kdWxlLmV4cG9ydHMgPSByb3V0ZXI7Cg==