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
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:
@@ -27,16 +27,16 @@
|
||||
gap: 0.75rem;
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
font-size: 2.25rem; /* +30% from 1.5rem */
|
||||
font-size: 2.25rem;
|
||||
text-decoration: none;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.nav-brand:hover {
|
||||
color: var(--primary-light);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Square logo: doubled from 2.5rem to 5rem */
|
||||
/* Square logo */
|
||||
.brand-logo {
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
@@ -45,7 +45,6 @@
|
||||
display: block;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
/* Subtle diffuse black drop shadow for depth */
|
||||
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.45))
|
||||
drop-shadow(0 1px 2px rgba(0, 0, 0, 0.30));
|
||||
}
|
||||
@@ -58,7 +57,7 @@
|
||||
height: 5rem;
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
|
||||
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 */
|
||||
@@ -68,7 +67,6 @@
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
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))
|
||||
drop-shadow(0 1px 2px rgba(0, 0, 0, 0.30));
|
||||
}
|
||||
@@ -76,6 +74,7 @@
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
@@ -99,9 +98,22 @@
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||
background: linear-gradient(135deg, rgba(201,148,10,0.2) 0%, rgba(139,37,0,0.2) 100%);
|
||||
color: var(--primary-light);
|
||||
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 {
|
||||
@@ -114,10 +126,9 @@
|
||||
}
|
||||
|
||||
.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 {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BrowserRouter as Router, Routes, Route, Link} from 'react-router-dom'
|
||||
import { Home, Users, Activity, Heart, FlaskConical } from 'lucide-react'
|
||||
import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom'
|
||||
import { Home, Users, Activity, Heart, FlaskConical, Settings } from 'lucide-react'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import DogList from './pages/DogList'
|
||||
import DogDetail from './pages/DogDetail'
|
||||
@@ -8,60 +8,69 @@ import LitterList from './pages/LitterList'
|
||||
import LitterDetail from './pages/LitterDetail'
|
||||
import BreedingCalendar from './pages/BreedingCalendar'
|
||||
import PairingSimulator from './pages/PairingSimulator'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import { useSettings } from './hooks/useSettings'
|
||||
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() {
|
||||
return (
|
||||
<Router>
|
||||
<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">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>
|
||||
<AppInner />
|
||||
</Router>
|
||||
)
|
||||
}
|
||||
|
||||
52
client/src/components/ChampionBadge.jsx
Normal file
52
client/src/components/ChampionBadge.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { X, Award } from 'lucide-react'
|
||||
import axios from 'axios'
|
||||
|
||||
function DogForm({ dog, onClose, onSave }) {
|
||||
@@ -12,9 +12,10 @@ function DogForm({ dog, onClose, onSave }) {
|
||||
color: '',
|
||||
microchip: '',
|
||||
notes: '',
|
||||
sire_id: null, // Changed from '' to null
|
||||
dam_id: null, // Changed from '' to null
|
||||
litter_id: null // Changed from '' to null
|
||||
sire_id: null,
|
||||
dam_id: null,
|
||||
litter_id: null,
|
||||
is_champion: false,
|
||||
})
|
||||
const [dogs, setDogs] = useState([])
|
||||
const [litters, setLitters] = useState([])
|
||||
@@ -36,9 +37,10 @@ function DogForm({ dog, onClose, onSave }) {
|
||||
color: dog.color || '',
|
||||
microchip: dog.microchip || '',
|
||||
notes: dog.notes || '',
|
||||
sire_id: dog.sire?.id || null, // Ensure null, not ''
|
||||
dam_id: dog.dam?.id || null, // Ensure null, not ''
|
||||
litter_id: dog.litter_id || null // Ensure null, not ''
|
||||
sire_id: dog.sire?.id || null,
|
||||
dam_id: dog.dam?.id || null,
|
||||
litter_id: dog.litter_id || null,
|
||||
is_champion: !!dog.is_champion,
|
||||
})
|
||||
setUseManualParents(!dog.litter_id)
|
||||
}
|
||||
@@ -48,8 +50,7 @@ function DogForm({ dog, onClose, onSave }) {
|
||||
try {
|
||||
const res = await axios.get('/api/dogs')
|
||||
setDogs(res.data || [])
|
||||
} catch (error) {
|
||||
console.error('Error fetching dogs:', error)
|
||||
} catch (e) {
|
||||
setDogs([])
|
||||
}
|
||||
}
|
||||
@@ -57,16 +58,11 @@ function DogForm({ dog, onClose, onSave }) {
|
||||
const fetchLitters = async () => {
|
||||
try {
|
||||
const res = await axios.get('/api/litters')
|
||||
const litterData = res.data || []
|
||||
setLitters(litterData)
|
||||
setLittersAvailable(litterData.length > 0)
|
||||
// Only default to manual if no litters exist
|
||||
if (litterData.length === 0) {
|
||||
setUseManualParents(true)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching litters:', error)
|
||||
// If endpoint fails, gracefully fallback to manual mode
|
||||
const data = res.data || []
|
||||
setLitters(data)
|
||||
setLittersAvailable(data.length > 0)
|
||||
if (data.length === 0) setUseManualParents(true)
|
||||
} catch (e) {
|
||||
setLitters([])
|
||||
setLittersAvailable(false)
|
||||
setUseManualParents(true)
|
||||
@@ -74,25 +70,27 @@ function DogForm({ dog, onClose, onSave }) {
|
||||
}
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target
|
||||
const { name, value, type, checked } = e.target
|
||||
|
||||
// Convert empty strings to null for ID fields
|
||||
let processedValue = value
|
||||
if (name === 'sire_id' || name === 'dam_id' || name === 'litter_id') {
|
||||
processedValue = value === '' ? null : parseInt(value)
|
||||
if (type === 'checkbox') {
|
||||
setFormData(prev => ({ ...prev, [name]: checked }))
|
||||
return
|
||||
}
|
||||
|
||||
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) {
|
||||
const selectedLitter = litters.find(l => l.id === parseInt(value))
|
||||
if (selectedLitter) {
|
||||
const sel = litters.find(l => l.id === parseInt(value))
|
||||
if (sel) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
sire_id: selectedLitter.sire_id,
|
||||
dam_id: selectedLitter.dam_id,
|
||||
breed: prev.breed || selectedLitter.sire_name?.split(' ')[0] || ''
|
||||
sire_id: sel.sire_id,
|
||||
dam_id: sel.dam_id,
|
||||
breed: prev.breed || sel.sire_name?.split(' ')[0] || ''
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -102,11 +100,10 @@ function DogForm({ dog, onClose, onSave }) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const submitData = {
|
||||
...formData,
|
||||
// Ensure null values are sent, not empty strings
|
||||
is_champion: formData.is_champion ? 1 : 0,
|
||||
sire_id: formData.sire_id || null,
|
||||
dam_id: formData.dam_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,
|
||||
color: formData.color || null,
|
||||
microchip: formData.microchip || null,
|
||||
notes: formData.notes || null
|
||||
notes: formData.notes || null,
|
||||
}
|
||||
|
||||
if (dog) {
|
||||
// Update existing dog
|
||||
await axios.put(`/api/dogs/${dog.id}`, submitData)
|
||||
} else {
|
||||
// Create new dog
|
||||
await axios.post('/api/dogs', submitData)
|
||||
}
|
||||
onSave()
|
||||
onClose()
|
||||
} catch (error) {
|
||||
setError(error.response?.data?.error || 'Failed to save dog')
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to save dog')
|
||||
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)
|
||||
|
||||
return (
|
||||
@@ -140,9 +134,7 @@ function DogForm({ dog, onClose, onSave }) {
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>{dog ? 'Edit Dog' : 'Add New Dog'}</h2>
|
||||
<button className="btn-icon" onClick={onClose}>
|
||||
<X size={24} />
|
||||
</button>
|
||||
<button className="btn-icon" onClick={onClose}><X size={24} /></button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="modal-body">
|
||||
@@ -151,48 +143,25 @@ function DogForm({ dog, onClose, onSave }) {
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<label className="label">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
className="input"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
<input type="text" name="name" className="input"
|
||||
value={formData.name} onChange={handleChange} required />
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="label">Registration Number</label>
|
||||
<input
|
||||
type="text"
|
||||
name="registration_number"
|
||||
className="input"
|
||||
value={formData.registration_number}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<input type="text" name="registration_number" className="input"
|
||||
value={formData.registration_number} onChange={handleChange} />
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="label">Breed *</label>
|
||||
<input
|
||||
type="text"
|
||||
name="breed"
|
||||
className="input"
|
||||
value={formData.breed}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
<input type="text" name="breed" className="input"
|
||||
value={formData.breed} onChange={handleChange} required />
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="label">Sex *</label>
|
||||
<select
|
||||
name="sex"
|
||||
className="input"
|
||||
value={formData.sex}
|
||||
onChange={handleChange}
|
||||
required
|
||||
>
|
||||
<select name="sex" className="input" value={formData.sex} onChange={handleChange} required>
|
||||
<option value="male">Male</option>
|
||||
<option value="female">Female</option>
|
||||
</select>
|
||||
@@ -200,62 +169,77 @@ function DogForm({ dog, onClose, onSave }) {
|
||||
|
||||
<div className="form-group">
|
||||
<label className="label">Birth Date</label>
|
||||
<input
|
||||
type="date"
|
||||
name="birth_date"
|
||||
className="input"
|
||||
value={formData.birth_date}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<input type="date" name="birth_date" className="input"
|
||||
value={formData.birth_date} onChange={handleChange} />
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="label">Color</label>
|
||||
<input
|
||||
type="text"
|
||||
name="color"
|
||||
className="input"
|
||||
value={formData.color}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<input type="text" name="color" className="input"
|
||||
value={formData.color} onChange={handleChange} />
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="label">Microchip Number</label>
|
||||
<input
|
||||
type="text"
|
||||
name="microchip"
|
||||
className="input"
|
||||
value={formData.microchip}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<input type="text" name="microchip" className="input"
|
||||
value={formData.microchip} onChange={handleChange} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Litter or Manual Parent Selection */}
|
||||
<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)' }}>
|
||||
{/* Champion Toggle */}
|
||||
<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 — 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>
|
||||
|
||||
{littersAvailable && (
|
||||
<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' }}>
|
||||
<input
|
||||
type="radio"
|
||||
name="parentMode"
|
||||
checked={!useManualParents}
|
||||
onChange={() => setUseManualParents(false)}
|
||||
style={{ width: '16px', height: '16px' }}
|
||||
/>
|
||||
<input type="radio" name="parentMode" checked={!useManualParents}
|
||||
onChange={() => setUseManualParents(false)} style={{ width: '16px', height: '16px' }} />
|
||||
<span>Link to Litter</span>
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', fontSize: '0.95rem' }}>
|
||||
<input
|
||||
type="radio"
|
||||
name="parentMode"
|
||||
checked={useManualParents}
|
||||
onChange={() => setUseManualParents(true)}
|
||||
style={{ width: '16px', height: '16px' }}
|
||||
/>
|
||||
<input type="radio" name="parentMode" checked={useManualParents}
|
||||
onChange={() => setUseManualParents(true)} style={{ width: '16px', height: '16px' }} />
|
||||
<span>Manual Parent Selection</span>
|
||||
</label>
|
||||
</div>
|
||||
@@ -264,12 +248,8 @@ function DogForm({ dog, onClose, onSave }) {
|
||||
{!useManualParents && littersAvailable ? (
|
||||
<div className="form-group" style={{ marginTop: '0.5rem' }}>
|
||||
<label className="label">Select Litter</label>
|
||||
<select
|
||||
name="litter_id"
|
||||
className="input"
|
||||
value={formData.litter_id || ''}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<select name="litter_id" className="input"
|
||||
value={formData.litter_id || ''} onChange={handleChange}>
|
||||
<option value="">No Litter</option>
|
||||
{litters.map(l => (
|
||||
<option key={l.id} value={l.id}>
|
||||
@@ -278,7 +258,7 @@ function DogForm({ dog, onClose, onSave }) {
|
||||
))}
|
||||
</select>
|
||||
{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
|
||||
</div>
|
||||
)}
|
||||
@@ -287,31 +267,18 @@ function DogForm({ dog, onClose, onSave }) {
|
||||
<div className="form-grid" style={{ marginTop: '0.5rem' }}>
|
||||
<div className="form-group">
|
||||
<label className="label">Sire (Father)</label>
|
||||
<select
|
||||
name="sire_id"
|
||||
className="input"
|
||||
value={formData.sire_id || ''}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<select name="sire_id" className="input"
|
||||
value={formData.sire_id || ''} onChange={handleChange}>
|
||||
<option value="">Unknown</option>
|
||||
{males.map(d => (
|
||||
<option key={d.id} value={d.id}>{d.name}</option>
|
||||
))}
|
||||
{males.map(d => <option key={d.id} value={d.id}>{d.name}{d.is_champion ? ' ✪' : ''}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="label">Dam (Mother)</label>
|
||||
<select
|
||||
name="dam_id"
|
||||
className="input"
|
||||
value={formData.dam_id || ''}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<select name="dam_id" className="input"
|
||||
value={formData.dam_id || ''} onChange={handleChange}>
|
||||
<option value="">Unknown</option>
|
||||
{females.map(d => (
|
||||
<option key={d.id} value={d.id}>{d.name}</option>
|
||||
))}
|
||||
{females.map(d => <option key={d.id} value={d.id}>{d.name}{d.is_champion ? ' ✪' : ''}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -320,19 +287,12 @@ function DogForm({ dog, onClose, onSave }) {
|
||||
|
||||
<div className="form-group" style={{ marginTop: '1rem' }}>
|
||||
<label className="label">Notes</label>
|
||||
<textarea
|
||||
name="notes"
|
||||
className="input"
|
||||
rows="4"
|
||||
value={formData.notes}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<textarea name="notes" className="input" rows="4"
|
||||
value={formData.notes} onChange={handleChange} />
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose} disabled={loading}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose} disabled={loading}>Cancel</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={loading}>
|
||||
{loading ? 'Saving...' : dog ? 'Update Dog' : 'Add Dog'}
|
||||
</button>
|
||||
|
||||
36
client/src/hooks/useSettings.js
Normal file
36
client/src/hooks/useSettings.js
Normal 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)
|
||||
}
|
||||
@@ -5,35 +5,45 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Modern dark color palette */
|
||||
--primary: #3b82f6;
|
||||
--primary-hover: #2563eb;
|
||||
--primary-light: #60a5fa;
|
||||
--accent: #8b5cf6;
|
||||
--success: #10b981;
|
||||
/* Primary accent: warm amber/copper to echo the gold-rust brand gradient */
|
||||
--primary: #c2862a;
|
||||
--primary-hover: #a86e1c;
|
||||
--primary-light: #e0a84a;
|
||||
|
||||
/* Secondary/accent: deep copper-red for punch */
|
||||
--accent: #9b3a10;
|
||||
|
||||
/* Status colors stay neutral/functional */
|
||||
--success: #22c55e;
|
||||
--danger: #ef4444;
|
||||
--warning: #f59e0b;
|
||||
|
||||
/* Dark theme */
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--bg-tertiary: #334155;
|
||||
--bg-elevated: #1e293b;
|
||||
/* Dark theme backgrounds — slightly warmer tones */
|
||||
--bg-primary: #0e0f0c;
|
||||
--bg-secondary: #1a1a15;
|
||||
--bg-tertiary: #2a2820;
|
||||
--bg-elevated: #222018;
|
||||
|
||||
/* Borders */
|
||||
--border: #334155;
|
||||
--border-light: #475569;
|
||||
/* Borders — warm dark */
|
||||
--border: #38352a;
|
||||
--border-light: #524e3e;
|
||||
|
||||
/* Text */
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #cbd5e1;
|
||||
--text-muted: #94a3b8;
|
||||
--text-primary: #f5f0e8;
|
||||
--text-secondary: #ccc4b0;
|
||||
--text-muted: #8c8472;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
|
||||
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.4);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.6);
|
||||
--shadow-sm: 0 1px 2px 0 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.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 */
|
||||
--radius: 0.5rem;
|
||||
@@ -130,14 +140,15 @@ h3 { font-size: 1.25rem; }
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
|
||||
color: var(--bg-primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--primary-hover);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
||||
background: linear-gradient(135deg, var(--primary-light) 0%, var(--primary) 100%);
|
||||
box-shadow: 0 4px 12px rgba(194, 134, 42, 0.4);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@@ -228,7 +239,7 @@ textarea:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
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 {
|
||||
@@ -243,7 +254,7 @@ textarea {
|
||||
select {
|
||||
cursor: pointer;
|
||||
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-repeat: no-repeat;
|
||||
background-size: 1.5em 1.5em;
|
||||
@@ -308,15 +319,50 @@ select {
|
||||
}
|
||||
|
||||
.badge-primary {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
background: rgba(194, 134, 42, 0.2);
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
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-overlay {
|
||||
position: fixed;
|
||||
@@ -324,7 +370,7 @@ select {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -475,9 +521,9 @@ select {
|
||||
}
|
||||
|
||||
.risk-low {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: var(--success);
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.risk-med {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { SettingsProvider } from './hooks/useSettings'
|
||||
import App from './App.jsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<SettingsProvider>
|
||||
<App />
|
||||
</SettingsProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -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 axios from 'axios'
|
||||
import DogForm from '../components/DogForm'
|
||||
import { ChampionBadge, ChampionBloodlineBadge } from '../components/ChampionBadge'
|
||||
|
||||
function DogDetail() {
|
||||
const { id } = useParams()
|
||||
@@ -14,9 +15,7 @@ function DogDetail() {
|
||||
const [selectedPhoto, setSelectedPhoto] = useState(0)
|
||||
const fileInputRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchDog()
|
||||
}, [id])
|
||||
useEffect(() => { fetchDog() }, [id])
|
||||
|
||||
const fetchDog = async () => {
|
||||
try {
|
||||
@@ -32,11 +31,9 @@ function DogDetail() {
|
||||
const handlePhotoUpload = async (e) => {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
|
||||
setUploading(true)
|
||||
const formData = new FormData()
|
||||
formData.append('photo', file)
|
||||
|
||||
try {
|
||||
await axios.post(`/api/dogs/${id}/photos`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
@@ -53,7 +50,6 @@ function DogDetail() {
|
||||
|
||||
const handleDeletePhoto = async (photoIndex) => {
|
||||
if (!confirm('Delete this photo?')) return
|
||||
|
||||
try {
|
||||
await axios.delete(`/api/dogs/${id}/photos/${photoIndex}`)
|
||||
fetchDog()
|
||||
@@ -72,24 +68,20 @@ function DogDetail() {
|
||||
const birth = new Date(birthDate)
|
||||
let years = today.getFullYear() - birth.getFullYear()
|
||||
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 (months === 0) return `${years} year${years !== 1 ? 's' : ''}`
|
||||
return `${years}y ${months}m`
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="container loading">Loading...</div>
|
||||
}
|
||||
const hasChampionBlood = (d) =>
|
||||
(d.sire && d.sire.is_champion) || (d.dam && d.dam.is_champion)
|
||||
|
||||
if (!dog) {
|
||||
return <div className="container">Dog not found</div>
|
||||
}
|
||||
if (loading) return <div className="container loading">Loading...</div>
|
||||
if (!dog) return <div className="container">Dog not found</div>
|
||||
|
||||
const isChampion = !!dog.is_champion
|
||||
const hasBloodline = !isChampion && hasChampionBlood(dog)
|
||||
|
||||
return (
|
||||
<div className="container" style={{ paddingTop: '2rem', paddingBottom: '3rem' }}>
|
||||
@@ -99,14 +91,18 @@ function DogDetail() {
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<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)' }}>
|
||||
<span>{dog.breed}</span>
|
||||
<span>•</span>
|
||||
<span>·</span>
|
||||
<span>{dog.sex === 'male' ? 'Male ♂' : 'Female ♀'}</span>
|
||||
{dog.birth_date && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>·</span>
|
||||
<span>{calculateAge(dog.birth_date)}</span>
|
||||
</>
|
||||
)}
|
||||
@@ -125,7 +121,7 @@ function DogDetail() {
|
||||
</div>
|
||||
|
||||
<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 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>
|
||||
@@ -138,46 +134,42 @@ function DogDetail() {
|
||||
<Upload size={14} />
|
||||
{uploading ? 'Uploading...' : 'Add'}
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handlePhotoUpload}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<input ref={fileInputRef} type="file" accept="image/*" onChange={handlePhotoUpload} style={{ display: 'none' }} />
|
||||
</div>
|
||||
|
||||
{dog.photo_urls && dog.photo_urls.length > 0 ? (
|
||||
<>
|
||||
{/* Main Photo */}
|
||||
<div style={{ position: 'relative', marginBottom: '0.75rem' }}>
|
||||
<img
|
||||
src={dog.photo_urls[selectedPhoto]}
|
||||
alt={dog.name}
|
||||
style={{
|
||||
width: '100%',
|
||||
aspectRatio: '1',
|
||||
objectFit: 'cover',
|
||||
width: '100%', aspectRatio: '1', objectFit: 'cover',
|
||||
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
|
||||
className="btn-icon"
|
||||
onClick={() => handleDeletePhoto(selectedPhoto)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '0.5rem',
|
||||
right: '0.5rem',
|
||||
background: 'rgba(15, 23, 42, 0.8)',
|
||||
position: 'absolute', top: '0.5rem', right: '0.5rem',
|
||||
background: 'rgba(14, 15, 12, 0.8)',
|
||||
backdropFilter: 'blur(8px)'
|
||||
}}
|
||||
>
|
||||
<Trash2 size={16} color="var(--danger)" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Thumbnail Strip */}
|
||||
{dog.photo_urls.length > 1 && (
|
||||
<div style={{ display: 'flex', gap: '0.5rem', overflowX: 'auto' }}>
|
||||
{dog.photo_urls.map((url, index) => (
|
||||
@@ -187,9 +179,7 @@ function DogDetail() {
|
||||
alt={`${dog.name} ${index + 1}`}
|
||||
onClick={() => setSelectedPhoto(index)}
|
||||
style={{
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
objectFit: 'cover',
|
||||
width: '60px', height: '60px', objectFit: 'cover',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
border: selectedPhoto === index ? '2px solid var(--primary)' : '1px solid var(--border)',
|
||||
@@ -213,18 +203,26 @@ function DogDetail() {
|
||||
<div>
|
||||
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
||||
<h2 style={{ fontSize: '1rem', marginBottom: '1rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Details</h2>
|
||||
|
||||
<div>
|
||||
<div className="info-row">
|
||||
<span className="info-label">Breed</span>
|
||||
<span className="info-value">{dog.breed}</span>
|
||||
</div>
|
||||
|
||||
<div className="info-row">
|
||||
<span className="info-label">Sex</span>
|
||||
<span className="info-value">{dog.sex === 'male' ? 'Male ♂' : 'Female ♀'}</span>
|
||||
</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 && (
|
||||
<div className="info-row">
|
||||
<span className="info-label"><Calendar size={14} style={{ display: 'inline', marginRight: '0.25rem' }} />Birth Date</span>
|
||||
@@ -234,21 +232,18 @@ function DogDetail() {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dog.color && (
|
||||
<div className="info-row">
|
||||
<span className="info-label">Color</span>
|
||||
<span className="info-value">{dog.color}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dog.registration_number && (
|
||||
<div className="info-row">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dog.microchip && (
|
||||
<div className="info-row">
|
||||
<span className="info-label"><Hash size={14} style={{ display: 'inline', marginRight: '0.25rem' }} />Microchip</span>
|
||||
@@ -265,9 +260,12 @@ function DogDetail() {
|
||||
<div>
|
||||
<div style={{ fontSize: '0.8125rem', color: 'var(--text-muted)', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Sire</div>
|
||||
{dog.sire ? (
|
||||
<Link to={`/dogs/${dog.sire.id}`} style={{ color: 'var(--primary)', fontWeight: 500, textDecoration: 'none' }}>
|
||||
{dog.sire.name}
|
||||
</Link>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', flexWrap: 'wrap' }}>
|
||||
<Link to={`/dogs/${dog.sire.id}`} style={{ color: 'var(--primary)', fontWeight: 500, textDecoration: 'none' }}>
|
||||
{dog.sire.name}
|
||||
</Link>
|
||||
{dog.sire.is_champion && <ChampionBadge />}
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>Unknown</span>
|
||||
)}
|
||||
@@ -275,9 +273,12 @@ function DogDetail() {
|
||||
<div>
|
||||
<div style={{ fontSize: '0.8125rem', color: 'var(--text-muted)', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Dam</div>
|
||||
{dog.dam ? (
|
||||
<Link to={`/dogs/${dog.dam.id}`} style={{ color: 'var(--primary)', fontWeight: 500, textDecoration: 'none' }}>
|
||||
{dog.dam.name}
|
||||
</Link>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', flexWrap: 'wrap' }}>
|
||||
<Link to={`/dogs/${dog.dam.id}`} style={{ color: 'var(--primary)', fontWeight: 500, textDecoration: 'none' }}>
|
||||
{dog.dam.name}
|
||||
</Link>
|
||||
{dog.dam.is_champion && <ChampionBadge />}
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>Unknown</span>
|
||||
)}
|
||||
@@ -313,7 +314,8 @@ function DogDetail() {
|
||||
transition: 'var(--transition)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
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={{ 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>
|
||||
))}
|
||||
</div>
|
||||
@@ -336,10 +341,7 @@ function DogDetail() {
|
||||
<DogForm
|
||||
dog={dog}
|
||||
onClose={() => setShowEditModal(false)}
|
||||
onSave={() => {
|
||||
fetchDog()
|
||||
setShowEditModal(false)
|
||||
}}
|
||||
onSave={() => { fetchDog(); setShowEditModal(false) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'
|
||||
import { Dog, Plus, Search, Calendar, Hash, ArrowRight } from 'lucide-react'
|
||||
import axios from 'axios'
|
||||
import DogForm from '../components/DogForm'
|
||||
import { ChampionBadge, ChampionBloodlineBadge } from '../components/ChampionBadge'
|
||||
|
||||
function DogList() {
|
||||
const [dogs, setDogs] = useState([])
|
||||
@@ -12,13 +13,8 @@ function DogList() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchDogs()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
filterDogs()
|
||||
}, [dogs, search, sexFilter])
|
||||
useEffect(() => { fetchDogs() }, [])
|
||||
useEffect(() => { filterDogs() }, [dogs, search, sexFilter])
|
||||
|
||||
const fetchDogs = async () => {
|
||||
try {
|
||||
@@ -33,24 +29,19 @@ function DogList() {
|
||||
|
||||
const filterDogs = () => {
|
||||
let filtered = dogs
|
||||
|
||||
if (search) {
|
||||
filtered = filtered.filter(dog =>
|
||||
dog.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(dog.registration_number && dog.registration_number.toLowerCase().includes(search.toLowerCase()))
|
||||
)
|
||||
}
|
||||
|
||||
if (sexFilter !== 'all') {
|
||||
filtered = filtered.filter(dog => dog.sex === sexFilter)
|
||||
}
|
||||
|
||||
setFilteredDogs(filtered)
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
fetchDogs()
|
||||
}
|
||||
const handleSave = () => { fetchDogs() }
|
||||
|
||||
const calculateAge = (birthDate) => {
|
||||
if (!birthDate) return null
|
||||
@@ -58,17 +49,16 @@ function DogList() {
|
||||
const birth = new Date(birthDate)
|
||||
let years = today.getFullYear() - birth.getFullYear()
|
||||
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 (months === 0) return `${years}y`
|
||||
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) {
|
||||
return <div className="container loading">Loading dogs...</div>
|
||||
}
|
||||
@@ -111,10 +101,7 @@ function DogList() {
|
||||
{(search || sexFilter !== 'all') && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => {
|
||||
setSearch('')
|
||||
setSexFilter('all')
|
||||
}}
|
||||
onClick={() => { setSearch(''); setSexFilter('all') }}
|
||||
style={{ padding: '0.625rem 1rem', fontSize: '0.875rem' }}
|
||||
>
|
||||
Clear
|
||||
@@ -169,65 +156,60 @@ function DogList() {
|
||||
e.currentTarget.style.boxShadow = 'var(--shadow-sm)'
|
||||
}}
|
||||
>
|
||||
{/* Avatar Photo */}
|
||||
{/* Avatar */}
|
||||
<div style={{
|
||||
width: '80px',
|
||||
height: '80px',
|
||||
flexShrink: 0,
|
||||
width: '80px', height: '80px', flexShrink: 0,
|
||||
borderRadius: 'var(--radius)',
|
||||
background: 'var(--bg-primary)',
|
||||
border: '2px solid var(--border)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden'
|
||||
border: dog.is_champion
|
||||
? '2px solid var(--champion-gold)'
|
||||
: hasChampionBlood(dog)
|
||||
? '2px solid var(--bloodline-amber)'
|
||||
: '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 ? (
|
||||
<img
|
||||
src={dog.photo_urls[0]}
|
||||
alt={dog.name}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover'
|
||||
}}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<Dog size={32} style={{ color: 'var(--text-muted)', opacity: 0.5 }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info Section */}
|
||||
{/* Info */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<h3 style={{
|
||||
fontSize: '1.125rem',
|
||||
marginBottom: '0.375rem',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
marginBottom: '0.25rem',
|
||||
display: 'flex', alignItems: 'center', gap: '0.5rem',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
{dog.name}
|
||||
<span style={{
|
||||
marginLeft: '0.5rem',
|
||||
fontSize: '1rem',
|
||||
color: dog.sex === 'male' ? 'var(--primary)' : '#ec4899'
|
||||
}}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{dog.name}
|
||||
</span>
|
||||
<span style={{ color: dog.sex === 'male' ? 'var(--primary)' : '#ec4899', fontSize: '1rem' }}>
|
||||
{dog.sex === 'male' ? '♂' : '♀'}
|
||||
</span>
|
||||
{dog.is_champion ? <ChampionBadge /> : hasChampionBlood(dog) ? <ChampionBloodlineBadge /> : null}
|
||||
</h3>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.75rem',
|
||||
fontSize: '0.8125rem',
|
||||
color: 'var(--text-secondary)',
|
||||
marginBottom: '0.5rem'
|
||||
display: 'flex', flexWrap: 'wrap', gap: '0.75rem',
|
||||
fontSize: '0.8125rem', color: 'var(--text-secondary)', marginBottom: '0.5rem'
|
||||
}}>
|
||||
<span>{dog.breed}</span>
|
||||
{dog.birth_date && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>·</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||
<Calendar size={12} />
|
||||
{calculateAge(dog.birth_date)}
|
||||
@@ -236,7 +218,7 @@ function DogList() {
|
||||
)}
|
||||
{dog.color && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>·</span>
|
||||
<span>{dog.color}</span>
|
||||
</>
|
||||
)}
|
||||
@@ -244,15 +226,12 @@ function DogList() {
|
||||
|
||||
{dog.registration_number && (
|
||||
<div style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem',
|
||||
display: 'inline-flex', alignItems: 'center', gap: '0.25rem',
|
||||
padding: '0.25rem 0.5rem',
|
||||
background: 'var(--bg-primary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: '0.75rem',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.75rem', fontFamily: 'monospace',
|
||||
color: 'var(--text-muted)'
|
||||
}}>
|
||||
<Hash size={10} />
|
||||
@@ -261,11 +240,7 @@ function DogList() {
|
||||
)}
|
||||
</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)" />
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
160
client/src/pages/SettingsPage.jsx
Normal file
160
client/src/pages/SettingsPage.jsx
Normal 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 & 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>
|
||||
)
|
||||
}
|
||||
@@ -2,174 +2,155 @@ const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
function initDatabase(dbPath) {
|
||||
// Ensure data directory exists
|
||||
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;
|
||||
}
|
||||
const dbPath = path.join(__dirname, '../../data');
|
||||
const db = new Database(path.join(dbPath, 'breedr.db'));
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
module.exports = { initDatabase, getDatabase };
|
||||
function initDatabase() {
|
||||
db.pragma('foreign_keys = ON');
|
||||
db.pragma('journal_mode = WAL');
|
||||
|
||||
// Run initialization if called directly
|
||||
if (require.main === module) {
|
||||
const dbPath = process.env.DB_PATH || path.join(__dirname, '../../data/breedr.db');
|
||||
console.log('\n==========================================');
|
||||
console.log('BREEDR Database Initialization');
|
||||
console.log('==========================================');
|
||||
console.log(`Database: ${dbPath}`);
|
||||
console.log('==========================================\n');
|
||||
initDatabase(dbPath);
|
||||
console.log('\n✓ Database ready!\n');
|
||||
// ── Dogs ────────────────────────────────────────────────────────────
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS dogs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
registration_number TEXT,
|
||||
breed TEXT NOT NULL,
|
||||
sex TEXT NOT NULL CHECK(sex IN ('male', 'female')),
|
||||
birth_date TEXT,
|
||||
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 };
|
||||
|
||||
@@ -1,96 +1 @@
|
||||
const express = require('express');
|
||||
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;
|
||||
Y29uc3QgZXhwcmVzcyA9IHJlcXVpcmUoJ2V4cHJlc3MnKTsKY29uc3QgY29ycyA9IHJlcXVpcmUoJ2NvcnMnKTsKY29uc3QgaGVsbWV0ID0gcmVxdWlyZSgnaGVsbWV0Jyk7CmNvbnN0IHBhdGggPSByZXF1aXJlKCdwYXRoJyk7CmNvbnN0IGZzID0gcmVxdWlyZSgnZnMnKTsKY29uc3QgeyBpbml0RGF0YWJhc2UgfSA9IHJlcXVpcmUoJy4vZGIvaW5pdCcpOwoKY29uc3QgYXBwID0gZXhwcmVzcygpOwpjb25zdCBQT1JUID0gcHJvY2Vzcy5lbnYuUE9SVCB8fCAzMDAwOwpjb25zdCBEQl9QQVRIID0gcHJvY2Vzcy5lbnYuREJfUEFUSCB8fCBwYXRoLmpvaW4oX19kaXJuYW1lLCAnLi4vZGF0YS9icmVlZHIuZGInKTsKY29uc3QgVVBMT0FEX1BBVEggPSBwcm9jZXNzLmVudi5VUExPQURfUEFUSCB8fCBwYXRoLmpvaW4oX19kaXJuYW1lLCAnLi4vdXBsb2FkcycpOwpjb25zdCBTVEFUSUNfUEFUSCA9IHByb2Nlc3MuZW52LlNUQVRJQ19QQVRIIHx8IHBhdGguam9pbihfX2Rpcm5hbWUsICcuLi9zdGF0aWMnKTsKCmNvbnN0IGRhdGFEaXIgPSBwYXRoLmRpcm5hbWUoREJfUEFUSCk7CmlmICghZnMuZXhpc3RzU3luYyhkYXRhRGlyKSkgZnMubWtkaXJTeW5jKGRhdGFEaXIsIHsgcmVjdXJzaXZlOiB0cnVlIH0pOwppZiAoIWZzLmV4aXN0c1N5bmMoVVBMT0FEX1BBVEgpKSBmcy5ta2RpclN5bmMoVVBMT0FEX1BBVEgsIHsgcmVjdXJzaXZlOiB0cnVlIH0pOwppZiAoIWZzLmV4aXN0c1N5bmMoU1RBVElDX1BBVEgpKSBmcy5ta2RpclN5bmMoU1RBVElDX1BBVEgsIHsgcmVjdXJzaXZlOiB0cnVlIH0pOwoKY29uc29sZS5sb2coJ0luaXRpYWxpemluZyBkYXRhYmFzZS4uLicpOwppbml0RGF0YWJhc2UoREJfUEFUSCk7CmNvbnNvbGUubG9nKCfinJMgRGF0YWJhc2UgcmVhZHkhXG4nKTsKCmFwcC51c2UoaGVsbWV0KHsgY29udGVudFNlY3VyaXR5UG9saWN5OiBmYWxzZSB9KSk7CmFwcC51c2UoY29ycygpKTsKYXBwLnVzZShleHByZXNzLmpzb24oKSk7CmFwcC51c2UoZXhwcmVzcy51cmxlbmNvZGVkKHsgZXh0ZW5kZWQ6IHRydWUgfSkpOwoKYXBwLnVzZSgnL3VwbG9hZHMnLCBleHByZXNzLnN0YXRpYyhVUExPQURfUEFUSCkpOwphcHAudXNlKCcvc3RhdGljJywgZXhwcmVzcy5zdGF0aWMoU1RBVElDX1BBVEgpKTsKYXBwLnVzZSgnL3VwbG9hZHMnLCAocmVxLCByZXMpID0+IHJlcy5zdGF0dXMoNDA0KS5qc29uKHsgZXJyb3I6ICdVcGxvYWQgbm90IGZvdW5kJyB9KSk7CmFwcC51c2UoJy9zdGF0aWMnLCAocmVxLCByZXMpID0+IHJlcy5zdGF0dXMoNDA0KS5qc29uKHsgZXJyb3I6ICdTdGF0aWMgYXNzZXQgbm90IGZvdW5kJyB9KSk7CgphcHAudXNlKCcvYXBpL2RvZ3MnLCByZXF1aXJlKCcuL3JvdXRlcy9kb2dzJykpOwphcHAudXNlKCcvYXBpL2xpdHRlcnMnLCByZXF1aXJlKCcuL3JvdXRlcy9saXR0ZXJzJykpOwphcHAudXNlKCcvYXBpL2hlYWx0aCcsIHJlcXVpcmUoJy4vcm91dGVzL2hlYWx0aCcpKTsKYXBwLnVzZSgnL2FwaS9wZWRpZ3JlZScsIHJlcXVpcmUoJy4vcm91dGVzL3BlZGlncmVlJykpOwphcHAudXNlKCcvYXBpL2JyZWVkaW5nJywgcmVxdWlyZSgnLi9yb3V0ZXMvYnJlZWRpbmcnKSk7CmFwcC51c2UoJy9hcGkvc2V0dGluZ3MnLCByZXF1aXJlKCcuL3JvdXRlcy9zZXR0aW5ncycpKTsKCmFwcC5nZXQoJy9hcGkvaGVhbHRoJywgKHJlcSwgcmVzKSA9PiB7CiAgcmVzLmpzb24oeyBzdGF0dXM6ICdvaycsIHRpbWVzdGFtcDogbmV3IERhdGUoKS50b0lTT1N0cmluZygpIH0pOwp9KTsKCmlmIChwcm9jZXNzLmVudi5OT0RFX0VOViA9PT0gJ3Byb2R1Y3Rpb24nKSB7CiAgY29uc3QgY2xpZW50QnVpbGRQYXRoID0gcGF0aC5qb2luKF9fZGlybmFtZSwgJy4uL2NsaWVudC9kaXN0Jyk7CiAgYXBwLnVzZShleHByZXNzLnN0YXRpYyhjbGllbnRCdWlsZFBhdGgpKTsKICBhcHAuZ2V0KC9eKD8hXC8oYXBpfHN0YXRpY3x1cGxvYWRzKVwvKS4qJC8sIChyZXEsIHJlcykgPT4gewogICAgcmVzLnNlbmRGaWxlKHBhdGguam9pbihjbGllbnRCdWlsZFBhdGgsICdpbmRleC5odG1sJykpOwogIH0pOwp9CgphcHAudXNlKChlcnIsIHJlcSwgcmVzLCBuZXh0KSA9PiB7CiAgY29uc29sZS5lcnJvcignRXJyb3I6JywgZXJyKTsKICByZXMuc3RhdHVzKGVyci5zdGF0dXMgfHwgNTAwKS5qc29uKHsKICAgIGVycm9yOiBlcnIubWVzc2FnZSB8fCAnSW50ZXJuYWwgc2VydmVyIGVycm9yJywKICAgIC4uLihwcm9jZXNzLmVudi5OT0RFX0VOViA9PT0gJ2RldmVsb3BtZW50JyAmJiB7IHN0YWNrOiBlcnIuc3RhY2sgfSkKICB9KTsKfSk7CgphcHAubGlzdGVuKFBPUlQsICcwLjAuMC4wJywgKCkgPT4gewogIGNvbnNvbGUubG9nKGBcbvCfkJUgQlJFRURSIFNlcnZlciBSdW5uaW5nYCk7CiAgY29uc29sZS5sb2coYD09PT09PT09PT09PT09PT09PT09PT09PT09PT09PWApOwogIGNvbnNvbGUubG9nKGBFbnZpcm9ubWVudDogJHtwcm9jZXNzLmVudi5OT0RFX0VOViB8fCAnZGV2ZWxvcG1lbnQnfWApOwogIGNvbnNvbGUubG9nKGBQb3J0OiAke1BPUlR9YCk7CiAgY29uc29sZS5sb2coYERhdGFiYXNlOiAke0RCX1BBVEh9YCk7CiAgY29uc29sZS5sb2coYFVwbG9hZHM6ICR7VVBMT0FEX1BBVEh9YCk7CiAgY29uc29sZS5sb2coYFN0YXRpYzogJHtTVEFUSUNfUEFUSH1gKTsKICBjb25zb2xlLmxvZyhgQWNjZXNzOiBodHRwOi8vbG9jYWxob3N0OiR7UE9SVH1gKTsKICBjb25zb2xlLmxvZyhgPT09PT09PT09PT09PT09PT09PT09PT09PT09PT09XG5gKTsKfSk7Cgptb2R1bGUuZXhwb3J0cyA9IGFwcDsK
|
||||
@@ -5,7 +5,6 @@ const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// Configure multer for photo uploads
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
const uploadPath = process.env.UPLOAD_PATH || path.join(__dirname, '../../uploads');
|
||||
@@ -19,12 +18,10 @@ const storage = multer.diskStorage({
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB limit
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowedTypes = /jpeg|jpg|png|gif|webp/;
|
||||
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
|
||||
const mimetype = allowedTypes.test(file.mimetype);
|
||||
if (extname && mimetype) {
|
||||
const allowed = /jpeg|jpg|png|gif|webp/;
|
||||
if (allowed.test(path.extname(file.originalname).toLowerCase()) && allowed.test(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
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 = (value) => {
|
||||
return (value === '' || value === undefined) ? null : value;
|
||||
};
|
||||
const emptyToNull = (v) => (v === '' || v === undefined) ? null : v;
|
||||
|
||||
// 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) => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const dogs = db.prepare(`
|
||||
SELECT id, name, registration_number, breed, sex, birth_date,
|
||||
color, microchip, photo_urls, notes, litter_id, is_active,
|
||||
created_at, updated_at
|
||||
SELECT ${DOG_COLS}
|
||||
FROM dogs
|
||||
WHERE is_active = 1
|
||||
ORDER BY name
|
||||
`).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 => {
|
||||
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);
|
||||
@@ -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) => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
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);
|
||||
const dog = db.prepare(`SELECT ${DOG_COLS} 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' });
|
||||
|
||||
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(`
|
||||
SELECT p.parent_type, d.*
|
||||
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 = ?
|
||||
`).all(req.params.id);
|
||||
|
||||
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(`
|
||||
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
|
||||
WHERE p.parent_id = ? AND d.is_active = 1
|
||||
`).all(req.params.id);
|
||||
@@ -105,66 +107,50 @@ router.get('/:id', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST create new dog
|
||||
// ── POST create dog ────────────────────────────────────────────────
|
||||
router.post('/', (req, res) => {
|
||||
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) {
|
||||
return res.status(400).json({ error: 'Name, breed, and sex are required' });
|
||||
}
|
||||
|
||||
const db = getDatabase();
|
||||
|
||||
// Insert dog (dogs table has NO sire/dam columns)
|
||||
const result = db.prepare(`
|
||||
INSERT INTO dogs (name, registration_number, breed, sex, birth_date, color, microchip, notes, litter_id, photo_urls)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO dogs (name, registration_number, breed, sex, birth_date, color,
|
||||
microchip, notes, litter_id, photo_urls, is_champion)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
name,
|
||||
emptyToNull(registration_number),
|
||||
breed,
|
||||
sex,
|
||||
breed, sex,
|
||||
emptyToNull(birth_date),
|
||||
emptyToNull(color),
|
||||
emptyToNull(microchip),
|
||||
emptyToNull(notes),
|
||||
emptyToNull(litter_id),
|
||||
'[]'
|
||||
'[]',
|
||||
is_champion ? 1 : 0
|
||||
);
|
||||
|
||||
const dogId = result.lastInsertRowid;
|
||||
console.log(`✓ Dog inserted with ID: ${dogId}`);
|
||||
|
||||
// Add sire relationship if provided
|
||||
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');
|
||||
console.log(` ✓ Sire relationship added`);
|
||||
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(dogId, sire_id, 'sire');
|
||||
}
|
||||
|
||||
// Add dam relationship if provided
|
||||
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');
|
||||
console.log(` ✓ Dam relationship added`);
|
||||
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(dogId, dam_id, 'dam');
|
||||
}
|
||||
|
||||
// Fetch the created dog
|
||||
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);
|
||||
const dog = db.prepare(`SELECT ${DOG_COLS} FROM dogs WHERE id = ?`).get(dogId);
|
||||
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);
|
||||
} catch (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) => {
|
||||
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();
|
||||
|
||||
// Update dog record (dogs table has NO sire/dam columns)
|
||||
db.prepare(`
|
||||
UPDATE dogs
|
||||
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 = ?
|
||||
`).run(
|
||||
name,
|
||||
emptyToNull(registration_number),
|
||||
breed,
|
||||
sex,
|
||||
breed, sex,
|
||||
emptyToNull(birth_date),
|
||||
emptyToNull(color),
|
||||
emptyToNull(microchip),
|
||||
emptyToNull(notes),
|
||||
emptyToNull(litter_id),
|
||||
is_champion ? 1 : 0,
|
||||
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);
|
||||
console.log(` ✓ Old parent relationships removed`);
|
||||
|
||||
// Add new sire relationship if provided
|
||||
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');
|
||||
console.log(` ✓ Sire relationship added`);
|
||||
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(req.params.id, sire_id, 'sire');
|
||||
}
|
||||
|
||||
// Add new dam relationship if provided
|
||||
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');
|
||||
console.log(` ✓ Dam relationship added`);
|
||||
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(req.params.id, dam_id, 'dam');
|
||||
}
|
||||
|
||||
// Fetch updated dog
|
||||
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);
|
||||
const dog = db.prepare(`SELECT ${DOG_COLS} FROM dogs WHERE id = ?`).get(req.params.id);
|
||||
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);
|
||||
} catch (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) => {
|
||||
try {
|
||||
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) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No file uploaded' });
|
||||
}
|
||||
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||
|
||||
const db = getDatabase();
|
||||
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) : [];
|
||||
photoUrls.push(`/uploads/${req.file.filename}`);
|
||||
|
||||
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 });
|
||||
@@ -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) => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
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) : [];
|
||||
const photoIndex = parseInt(req.params.photoIndex);
|
||||
|
||||
if (photoIndex >= 0 && photoIndex < photoUrls.length) {
|
||||
const photoPath = path.join(process.env.UPLOAD_PATH || path.join(__dirname, '../../uploads'), path.basename(photoUrls[photoIndex]));
|
||||
|
||||
// Delete file from disk
|
||||
if (fs.existsSync(photoPath)) {
|
||||
fs.unlinkSync(photoPath);
|
||||
}
|
||||
|
||||
const photoPath = path.join(
|
||||
process.env.UPLOAD_PATH || path.join(__dirname, '../../uploads'),
|
||||
path.basename(photoUrls[photoIndex])
|
||||
);
|
||||
if (fs.existsSync(photoPath)) fs.unlinkSync(photoPath);
|
||||
photoUrls.splice(photoIndex, 1);
|
||||
db.prepare('UPDATE dogs SET photo_urls = ? WHERE id = ?').run(JSON.stringify(photoUrls), req.params.id);
|
||||
}
|
||||
|
||||
1
server/routes/settings.js
Normal file
1
server/routes/settings.js
Normal file
@@ -0,0 +1 @@
|
||||
Y29uc3QgZXhwcmVzcyA9IHJlcXVpcmUoJ2V4cHJlc3MnKTsKY29uc3Qgcm91dGVyID0gZXhwcmVzcy5Sb3V0ZXIoKTsKY29uc3QgeyBnZXREYXRhYmFzZSB9ID0gcmVxdWlyZSgnLi4vZGIvaW5pdCcpOwoKLy8gR0VUIGFsbCBzZXR0aW5ncwpyb3V0ZXIuZ2V0KCcvJywgKHJlcSwgcmVzKSA9PiB7CiAgdHJ5IHsKICAgIGNvbnN0IGRiID0gZ2V0RGF0YWJhc2UoKTsKICAgIGNvbnN0IHJvd3MgPSBkYi5wcmVwYXJlKCdTRUxFQ1Qga2V5LCB2YWx1ZSBGUk9NIHNldHRpbmdzJykuYWxsKCk7CiAgICBjb25zdCBzZXR0aW5ncyA9IHt9OwogICAgcm93cy5mb3JFYWNoKHIgPT4geyBzZXR0aW5nc1tyLmtleV0gPSByLnZhbHVlOyB9KTsKICAgIHJlcy5qc29uKHNldHRpbmdzKTsKICB9IGNhdGNoIChlcnJvcikgewogICAgcmVzLnN0YXR1cyg1MDApLmpzb24oeyBlcnJvcjogZXJyb3IubWVzc2FnZSB9KTsKICB9Cn0pOwoKLy8gUFVUIHVwZGF0ZSBzZXR0aW5ncwpyb3V0ZXIucHV0KCcvJywgKHJlcSwgcmVzKSA9PiB7CiAgdHJ5IHsKICAgIGNvbnN0IGRiID0gZ2V0RGF0YWJhc2UoKTsKICAgIGNvbnN0IHVwc2VydCA9IGRiLnByZXBhcmUoJ0lOU0VSVCBJTlRPIHNldHRpbmdzIChrZXksIHZhbHVlKSBWQUxVRVMgKD8sID8pIE9OIENPTUZMSUNIVCBLRVBVUERBVEU9ZXhjbHVkZWQudmFsdWUnKTsKICAgIGNvbnN0IHVwZGF0ZU1hbnkgPSBkYi50cmFuc2FjdGlvbigoZW50cmllcykgPT4gewogICAgICBmb3IgKGNvbnN0IFtrZXksIHZhbHVlXSBvZiBPYmplY3QuZW50cmllcyhlbnRyaWVzKSkgewogICAgICAgIHVwc2VydC5ydW4oa2V5LCB2YWx1ZSA9PSBudWxsID8gJycgOiBTdHJpbmcodmFsdWUpKTsKICAgICAgfQogICAgfSk7CiAgICB1cGRhdGVNYW55KHJlcS5ib2R5KTsKICAgIHJlcy5qc29uKHsgbWVzc2FnZTogJ1NldHRpbmdzIHNhdmVkJyB9KTsKICB9IGNhdGNoIChlcnJvcikgewogICAgcmVzLnN0YXR1cyg1MDApLmpzb24oeyBlcnJvcjogZXJyb3IubWVzc2FnZSB9KTsKICB9Cn0pOwoKbW9kdWxlLmV4cG9ydHMgPSByb3V0ZXI7Cg==
|
||||
Reference in New Issue
Block a user