feat/ui-theme-settings-champion #29
@@ -27,16 +27,16 @@
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 2.25rem; /* +30% from 1.5rem */
|
font-size: 2.25rem;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-brand:hover {
|
.nav-brand:hover {
|
||||||
color: var(--primary-light);
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Square logo: doubled from 2.5rem to 5rem */
|
/* Square logo */
|
||||||
.brand-logo {
|
.brand-logo {
|
||||||
width: 5rem;
|
width: 5rem;
|
||||||
height: 5rem;
|
height: 5rem;
|
||||||
@@ -45,7 +45,6 @@
|
|||||||
display: block;
|
display: block;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
/* Subtle diffuse black drop shadow for depth */
|
|
||||||
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.45))
|
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.45))
|
||||||
drop-shadow(0 1px 2px rgba(0, 0, 0, 0.30));
|
drop-shadow(0 1px 2px rgba(0, 0, 0, 0.30));
|
||||||
}
|
}
|
||||||
@@ -58,7 +57,7 @@
|
|||||||
height: 5rem;
|
height: 5rem;
|
||||||
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
|
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
box-shadow: 0 4px 12px rgba(194, 134, 42, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Title gradient: medium-dark gold → rusty dark red-gold */
|
/* Title gradient: medium-dark gold → rusty dark red-gold */
|
||||||
@@ -68,7 +67,6 @@
|
|||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
/* text-shadow doesn't work with background-clip:text — use filter instead */
|
|
||||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.50))
|
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.50))
|
||||||
drop-shadow(0 1px 2px rgba(0, 0, 0, 0.30));
|
drop-shadow(0 1px 2px rgba(0, 0, 0, 0.30));
|
||||||
}
|
}
|
||||||
@@ -76,6 +74,7 @@
|
|||||||
.nav-links {
|
.nav-links {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link {
|
.nav-link {
|
||||||
@@ -99,9 +98,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-link.active {
|
.nav-link.active {
|
||||||
background: var(--primary);
|
background: linear-gradient(135deg, rgba(201,148,10,0.2) 0%, rgba(139,37,0,0.2) 100%);
|
||||||
color: white;
|
color: var(--primary-light);
|
||||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
border-color: rgba(194, 134, 42, 0.4);
|
||||||
|
box-shadow: 0 2px 8px rgba(194, 134, 42, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Settings link — slightly different treatment, sits at end */
|
||||||
|
.nav-link-settings {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link-settings:hover {
|
||||||
|
color: var(--primary-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-content {
|
.main-content {
|
||||||
@@ -114,10 +126,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-brand {
|
.nav-brand {
|
||||||
font-size: 1.625rem; /* +30% from 1.25rem */
|
font-size: 1.625rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scale square logo down on mobile (doubled from 2rem) */
|
|
||||||
.brand-logo {
|
.brand-logo {
|
||||||
width: 4rem;
|
width: 4rem;
|
||||||
height: 4rem;
|
height: 4rem;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { BrowserRouter as Router, Routes, Route, Link} from 'react-router-dom'
|
import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom'
|
||||||
import { Home, Users, Activity, Heart, FlaskConical } from 'lucide-react'
|
import { Home, Users, Activity, Heart, FlaskConical, Settings } from 'lucide-react'
|
||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
import DogList from './pages/DogList'
|
import DogList from './pages/DogList'
|
||||||
import DogDetail from './pages/DogDetail'
|
import DogDetail from './pages/DogDetail'
|
||||||
@@ -8,60 +8,69 @@ import LitterList from './pages/LitterList'
|
|||||||
import LitterDetail from './pages/LitterDetail'
|
import LitterDetail from './pages/LitterDetail'
|
||||||
import BreedingCalendar from './pages/BreedingCalendar'
|
import BreedingCalendar from './pages/BreedingCalendar'
|
||||||
import PairingSimulator from './pages/PairingSimulator'
|
import PairingSimulator from './pages/PairingSimulator'
|
||||||
|
import SettingsPage from './pages/SettingsPage'
|
||||||
|
import { useSettings } from './hooks/useSettings'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
|
||||||
|
function NavLink({ to, icon: Icon, label }) {
|
||||||
|
const location = useLocation()
|
||||||
|
const isActive = location.pathname === to
|
||||||
|
return (
|
||||||
|
<Link to={to} className={`nav-link${isActive ? ' active' : ''}`}>
|
||||||
|
<Icon size={20} />
|
||||||
|
<span>{label}</span>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppInner() {
|
||||||
|
const { settings } = useSettings()
|
||||||
|
const kennelName = settings?.kennel_name || 'BREEDR'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<nav className="navbar">
|
||||||
|
<div className="container">
|
||||||
|
<div className="nav-brand">
|
||||||
|
<img
|
||||||
|
src="/static/br-logo.png"
|
||||||
|
alt="BREEDR Logo"
|
||||||
|
className="brand-logo"
|
||||||
|
/>
|
||||||
|
<span className="brand-text">{kennelName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="nav-links">
|
||||||
|
<NavLink to="/" icon={Home} label="Dashboard" />
|
||||||
|
<NavLink to="/dogs" icon={Users} label="Dogs" />
|
||||||
|
<NavLink to="/litters" icon={Activity} label="Litters" />
|
||||||
|
<NavLink to="/breeding" icon={Heart} label="Breeding" />
|
||||||
|
<NavLink to="/pairing" icon={FlaskConical} label="Pairing" />
|
||||||
|
<NavLink to="/settings" icon={Settings} label="Settings" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main className="main-content">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/dogs" element={<DogList />} />
|
||||||
|
<Route path="/dogs/:id" element={<DogDetail />} />
|
||||||
|
<Route path="/pedigree/:id" element={<PedigreeView />} />
|
||||||
|
<Route path="/litters" element={<LitterList />} />
|
||||||
|
<Route path="/litters/:id" element={<LitterDetail />} />
|
||||||
|
<Route path="/breeding" element={<BreedingCalendar />} />
|
||||||
|
<Route path="/pairing" element={<PairingSimulator />} />
|
||||||
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<Router>
|
<Router>
|
||||||
<div className="app">
|
<AppInner />
|
||||||
<nav className="navbar">
|
|
||||||
<div className="container">
|
|
||||||
<div className="nav-brand">
|
|
||||||
<img
|
|
||||||
src="/static/br-logo.png"
|
|
||||||
alt="BREEDR Logo"
|
|
||||||
className="brand-logo"
|
|
||||||
/>
|
|
||||||
<span className="brand-text">BREEDR</span>
|
|
||||||
</div>
|
|
||||||
<div className="nav-links">
|
|
||||||
<Link to="/" className="nav-link">
|
|
||||||
<Home size={20} />
|
|
||||||
<span>Dashboard</span>
|
|
||||||
</Link>
|
|
||||||
<Link to="/dogs" className="nav-link">
|
|
||||||
<Users size={20} />
|
|
||||||
<span>Dogs</span>
|
|
||||||
</Link>
|
|
||||||
<Link to="/litters" className="nav-link">
|
|
||||||
<Activity size={20} />
|
|
||||||
<span>Litters</span>
|
|
||||||
</Link>
|
|
||||||
<Link to="/breeding" className="nav-link">
|
|
||||||
<Heart size={20} />
|
|
||||||
<span>Breeding</span>
|
|
||||||
</Link>
|
|
||||||
<Link to="/pairing" className="nav-link">
|
|
||||||
<FlaskConical size={20} />
|
|
||||||
<span>Pairing</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main className="main-content">
|
|
||||||
<Routes>
|
|
||||||
<Route path="/" element={<Dashboard />} />
|
|
||||||
<Route path="/dogs" element={<DogList />} />
|
|
||||||
<Route path="/dogs/:id" element={<DogDetail />} />
|
|
||||||
<Route path="/pedigree/:id" element={<PedigreeView />} />
|
|
||||||
<Route path="/litters" element={<LitterList />} />
|
|
||||||
<Route path="/litters/:id" element={<LitterDetail />} />
|
|
||||||
<Route path="/breeding" element={<BreedingCalendar />} />
|
|
||||||
<Route path="/pairing" element={<PairingSimulator />} />
|
|
||||||
</Routes>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</Router>
|
</Router>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
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 { useState, useEffect } from 'react'
|
||||||
import { X } from 'lucide-react'
|
import { X, Award } from 'lucide-react'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
function DogForm({ dog, onClose, onSave }) {
|
function DogForm({ dog, onClose, onSave }) {
|
||||||
@@ -12,9 +12,10 @@ function DogForm({ dog, onClose, onSave }) {
|
|||||||
color: '',
|
color: '',
|
||||||
microchip: '',
|
microchip: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
sire_id: null, // Changed from '' to null
|
sire_id: null,
|
||||||
dam_id: null, // Changed from '' to null
|
dam_id: null,
|
||||||
litter_id: null // Changed from '' to null
|
litter_id: null,
|
||||||
|
is_champion: false,
|
||||||
})
|
})
|
||||||
const [dogs, setDogs] = useState([])
|
const [dogs, setDogs] = useState([])
|
||||||
const [litters, setLitters] = useState([])
|
const [litters, setLitters] = useState([])
|
||||||
@@ -36,9 +37,10 @@ function DogForm({ dog, onClose, onSave }) {
|
|||||||
color: dog.color || '',
|
color: dog.color || '',
|
||||||
microchip: dog.microchip || '',
|
microchip: dog.microchip || '',
|
||||||
notes: dog.notes || '',
|
notes: dog.notes || '',
|
||||||
sire_id: dog.sire?.id || null, // Ensure null, not ''
|
sire_id: dog.sire?.id || null,
|
||||||
dam_id: dog.dam?.id || null, // Ensure null, not ''
|
dam_id: dog.dam?.id || null,
|
||||||
litter_id: dog.litter_id || null // Ensure null, not ''
|
litter_id: dog.litter_id || null,
|
||||||
|
is_champion: !!dog.is_champion,
|
||||||
})
|
})
|
||||||
setUseManualParents(!dog.litter_id)
|
setUseManualParents(!dog.litter_id)
|
||||||
}
|
}
|
||||||
@@ -48,8 +50,7 @@ function DogForm({ dog, onClose, onSave }) {
|
|||||||
try {
|
try {
|
||||||
const res = await axios.get('/api/dogs')
|
const res = await axios.get('/api/dogs')
|
||||||
setDogs(res.data || [])
|
setDogs(res.data || [])
|
||||||
} catch (error) {
|
} catch (e) {
|
||||||
console.error('Error fetching dogs:', error)
|
|
||||||
setDogs([])
|
setDogs([])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,16 +58,11 @@ function DogForm({ dog, onClose, onSave }) {
|
|||||||
const fetchLitters = async () => {
|
const fetchLitters = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await axios.get('/api/litters')
|
const res = await axios.get('/api/litters')
|
||||||
const litterData = res.data || []
|
const data = res.data || []
|
||||||
setLitters(litterData)
|
setLitters(data)
|
||||||
setLittersAvailable(litterData.length > 0)
|
setLittersAvailable(data.length > 0)
|
||||||
// Only default to manual if no litters exist
|
if (data.length === 0) setUseManualParents(true)
|
||||||
if (litterData.length === 0) {
|
} catch (e) {
|
||||||
setUseManualParents(true)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching litters:', error)
|
|
||||||
// If endpoint fails, gracefully fallback to manual mode
|
|
||||||
setLitters([])
|
setLitters([])
|
||||||
setLittersAvailable(false)
|
setLittersAvailable(false)
|
||||||
setUseManualParents(true)
|
setUseManualParents(true)
|
||||||
@@ -74,25 +70,27 @@ function DogForm({ dog, onClose, onSave }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleChange = (e) => {
|
const handleChange = (e) => {
|
||||||
const { name, value } = e.target
|
const { name, value, type, checked } = e.target
|
||||||
|
|
||||||
// Convert empty strings to null for ID fields
|
if (type === 'checkbox') {
|
||||||
let processedValue = value
|
setFormData(prev => ({ ...prev, [name]: checked }))
|
||||||
if (name === 'sire_id' || name === 'dam_id' || name === 'litter_id') {
|
return
|
||||||
processedValue = value === '' ? null : parseInt(value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setFormData(prev => ({ ...prev, [name]: processedValue }))
|
let processed = value
|
||||||
|
if (name === 'sire_id' || name === 'dam_id' || name === 'litter_id') {
|
||||||
// If litter is selected, auto-populate parents
|
processed = value === '' ? null : parseInt(value)
|
||||||
|
}
|
||||||
|
setFormData(prev => ({ ...prev, [name]: processed }))
|
||||||
|
|
||||||
if (name === 'litter_id' && value) {
|
if (name === 'litter_id' && value) {
|
||||||
const selectedLitter = litters.find(l => l.id === parseInt(value))
|
const sel = litters.find(l => l.id === parseInt(value))
|
||||||
if (selectedLitter) {
|
if (sel) {
|
||||||
setFormData(prev => ({
|
setFormData(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
sire_id: selectedLitter.sire_id,
|
sire_id: sel.sire_id,
|
||||||
dam_id: selectedLitter.dam_id,
|
dam_id: sel.dam_id,
|
||||||
breed: prev.breed || selectedLitter.sire_name?.split(' ')[0] || ''
|
breed: prev.breed || sel.sire_name?.split(' ')[0] || ''
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,11 +100,10 @@ function DogForm({ dog, onClose, onSave }) {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const submitData = {
|
const submitData = {
|
||||||
...formData,
|
...formData,
|
||||||
// Ensure null values are sent, not empty strings
|
is_champion: formData.is_champion ? 1 : 0,
|
||||||
sire_id: formData.sire_id || null,
|
sire_id: formData.sire_id || null,
|
||||||
dam_id: formData.dam_id || null,
|
dam_id: formData.dam_id || null,
|
||||||
litter_id: useManualParents ? null : (formData.litter_id || null),
|
litter_id: useManualParents ? null : (formData.litter_id || null),
|
||||||
@@ -114,25 +111,22 @@ function DogForm({ dog, onClose, onSave }) {
|
|||||||
birth_date: formData.birth_date || null,
|
birth_date: formData.birth_date || null,
|
||||||
color: formData.color || null,
|
color: formData.color || null,
|
||||||
microchip: formData.microchip || null,
|
microchip: formData.microchip || null,
|
||||||
notes: formData.notes || null
|
notes: formData.notes || null,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dog) {
|
if (dog) {
|
||||||
// Update existing dog
|
|
||||||
await axios.put(`/api/dogs/${dog.id}`, submitData)
|
await axios.put(`/api/dogs/${dog.id}`, submitData)
|
||||||
} else {
|
} else {
|
||||||
// Create new dog
|
|
||||||
await axios.post('/api/dogs', submitData)
|
await axios.post('/api/dogs', submitData)
|
||||||
}
|
}
|
||||||
onSave()
|
onSave()
|
||||||
onClose()
|
onClose()
|
||||||
} catch (error) {
|
} catch (err) {
|
||||||
setError(error.response?.data?.error || 'Failed to save dog')
|
setError(err.response?.data?.error || 'Failed to save dog')
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const males = dogs.filter(d => d.sex === 'male' && d.id !== dog?.id)
|
const males = dogs.filter(d => d.sex === 'male' && d.id !== dog?.id)
|
||||||
const females = dogs.filter(d => d.sex === 'female' && d.id !== dog?.id)
|
const females = dogs.filter(d => d.sex === 'female' && d.id !== dog?.id)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -140,9 +134,7 @@ function DogForm({ dog, onClose, onSave }) {
|
|||||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
<h2>{dog ? 'Edit Dog' : 'Add New Dog'}</h2>
|
<h2>{dog ? 'Edit Dog' : 'Add New Dog'}</h2>
|
||||||
<button className="btn-icon" onClick={onClose}>
|
<button className="btn-icon" onClick={onClose}><X size={24} /></button>
|
||||||
<X size={24} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="modal-body">
|
<form onSubmit={handleSubmit} className="modal-body">
|
||||||
@@ -151,48 +143,25 @@ function DogForm({ dog, onClose, onSave }) {
|
|||||||
<div className="form-grid">
|
<div className="form-grid">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="label">Name *</label>
|
<label className="label">Name *</label>
|
||||||
<input
|
<input type="text" name="name" className="input"
|
||||||
type="text"
|
value={formData.name} onChange={handleChange} required />
|
||||||
name="name"
|
|
||||||
className="input"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={handleChange}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="label">Registration Number</label>
|
<label className="label">Registration Number</label>
|
||||||
<input
|
<input type="text" name="registration_number" className="input"
|
||||||
type="text"
|
value={formData.registration_number} onChange={handleChange} />
|
||||||
name="registration_number"
|
|
||||||
className="input"
|
|
||||||
value={formData.registration_number}
|
|
||||||
onChange={handleChange}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="label">Breed *</label>
|
<label className="label">Breed *</label>
|
||||||
<input
|
<input type="text" name="breed" className="input"
|
||||||
type="text"
|
value={formData.breed} onChange={handleChange} required />
|
||||||
name="breed"
|
|
||||||
className="input"
|
|
||||||
value={formData.breed}
|
|
||||||
onChange={handleChange}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="label">Sex *</label>
|
<label className="label">Sex *</label>
|
||||||
<select
|
<select name="sex" className="input" value={formData.sex} onChange={handleChange} required>
|
||||||
name="sex"
|
|
||||||
className="input"
|
|
||||||
value={formData.sex}
|
|
||||||
onChange={handleChange}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<option value="male">Male</option>
|
<option value="male">Male</option>
|
||||||
<option value="female">Female</option>
|
<option value="female">Female</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -200,62 +169,77 @@ function DogForm({ dog, onClose, onSave }) {
|
|||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="label">Birth Date</label>
|
<label className="label">Birth Date</label>
|
||||||
<input
|
<input type="date" name="birth_date" className="input"
|
||||||
type="date"
|
value={formData.birth_date} onChange={handleChange} />
|
||||||
name="birth_date"
|
|
||||||
className="input"
|
|
||||||
value={formData.birth_date}
|
|
||||||
onChange={handleChange}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="label">Color</label>
|
<label className="label">Color</label>
|
||||||
<input
|
<input type="text" name="color" className="input"
|
||||||
type="text"
|
value={formData.color} onChange={handleChange} />
|
||||||
name="color"
|
|
||||||
className="input"
|
|
||||||
value={formData.color}
|
|
||||||
onChange={handleChange}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="label">Microchip Number</label>
|
<label className="label">Microchip Number</label>
|
||||||
<input
|
<input type="text" name="microchip" className="input"
|
||||||
type="text"
|
value={formData.microchip} onChange={handleChange} />
|
||||||
name="microchip"
|
|
||||||
className="input"
|
|
||||||
value={formData.microchip}
|
|
||||||
onChange={handleChange}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Litter or Manual Parent Selection */}
|
{/* Champion Toggle */}
|
||||||
<div style={{ marginTop: '1.5rem', padding: '1rem', background: 'rgba(99, 102, 241, 0.05)', borderRadius: '8px', border: '1px solid rgba(99, 102, 241, 0.2)' }}>
|
<div style={{
|
||||||
|
marginTop: '1.25rem',
|
||||||
|
padding: '0.875rem 1rem',
|
||||||
|
background: formData.is_champion ? 'rgba(194, 134, 42, 0.08)' : 'var(--bg-primary)',
|
||||||
|
border: formData.is_champion ? '1px solid var(--champion-gold)' : '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.75rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
onClick={() => setFormData(prev => ({ ...prev, is_champion: !prev.is_champion }))}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="is_champion"
|
||||||
|
id="is_champion"
|
||||||
|
checked={!!formData.is_champion}
|
||||||
|
onChange={handleChange}
|
||||||
|
style={{ width: '18px', height: '18px', cursor: 'pointer', accentColor: 'var(--champion-gold)' }}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<Award size={18} style={{ color: formData.is_champion ? 'var(--champion-gold)' : 'var(--text-muted)' }} />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, color: formData.is_champion ? 'var(--champion-gold)' : 'var(--text-primary)', fontSize: '0.9375rem' }}>
|
||||||
|
Champion
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
||||||
|
Mark this dog as a titled champion — offspring will display a Champion Bloodline badge
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Parent Section */}
|
||||||
|
<div style={{
|
||||||
|
marginTop: '1.5rem', padding: '1rem',
|
||||||
|
background: 'rgba(194, 134, 42, 0.04)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid rgba(194, 134, 42, 0.15)'
|
||||||
|
}}>
|
||||||
<label className="label" style={{ marginBottom: '0.75rem', display: 'block', fontWeight: '600' }}>Parent Information</label>
|
<label className="label" style={{ marginBottom: '0.75rem', display: 'block', fontWeight: '600' }}>Parent Information</label>
|
||||||
|
|
||||||
{littersAvailable && (
|
{littersAvailable && (
|
||||||
<div style={{ display: 'flex', gap: '1.5rem', marginBottom: '1rem', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '1.5rem', marginBottom: '1rem', flexWrap: 'wrap' }}>
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', fontSize: '0.95rem' }}>
|
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', fontSize: '0.95rem' }}>
|
||||||
<input
|
<input type="radio" name="parentMode" checked={!useManualParents}
|
||||||
type="radio"
|
onChange={() => setUseManualParents(false)} style={{ width: '16px', height: '16px' }} />
|
||||||
name="parentMode"
|
|
||||||
checked={!useManualParents}
|
|
||||||
onChange={() => setUseManualParents(false)}
|
|
||||||
style={{ width: '16px', height: '16px' }}
|
|
||||||
/>
|
|
||||||
<span>Link to Litter</span>
|
<span>Link to Litter</span>
|
||||||
</label>
|
</label>
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', fontSize: '0.95rem' }}>
|
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', fontSize: '0.95rem' }}>
|
||||||
<input
|
<input type="radio" name="parentMode" checked={useManualParents}
|
||||||
type="radio"
|
onChange={() => setUseManualParents(true)} style={{ width: '16px', height: '16px' }} />
|
||||||
name="parentMode"
|
|
||||||
checked={useManualParents}
|
|
||||||
onChange={() => setUseManualParents(true)}
|
|
||||||
style={{ width: '16px', height: '16px' }}
|
|
||||||
/>
|
|
||||||
<span>Manual Parent Selection</span>
|
<span>Manual Parent Selection</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -264,12 +248,8 @@ function DogForm({ dog, onClose, onSave }) {
|
|||||||
{!useManualParents && littersAvailable ? (
|
{!useManualParents && littersAvailable ? (
|
||||||
<div className="form-group" style={{ marginTop: '0.5rem' }}>
|
<div className="form-group" style={{ marginTop: '0.5rem' }}>
|
||||||
<label className="label">Select Litter</label>
|
<label className="label">Select Litter</label>
|
||||||
<select
|
<select name="litter_id" className="input"
|
||||||
name="litter_id"
|
value={formData.litter_id || ''} onChange={handleChange}>
|
||||||
className="input"
|
|
||||||
value={formData.litter_id || ''}
|
|
||||||
onChange={handleChange}
|
|
||||||
>
|
|
||||||
<option value="">No Litter</option>
|
<option value="">No Litter</option>
|
||||||
{litters.map(l => (
|
{litters.map(l => (
|
||||||
<option key={l.id} value={l.id}>
|
<option key={l.id} value={l.id}>
|
||||||
@@ -278,7 +258,7 @@ function DogForm({ dog, onClose, onSave }) {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{formData.litter_id && (
|
{formData.litter_id && (
|
||||||
<div style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: '#6366f1', fontStyle: 'italic' }}>
|
<div style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: 'var(--primary)', fontStyle: 'italic' }}>
|
||||||
✓ Parents will be automatically set from the selected litter
|
✓ Parents will be automatically set from the selected litter
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -287,31 +267,18 @@ function DogForm({ dog, onClose, onSave }) {
|
|||||||
<div className="form-grid" style={{ marginTop: '0.5rem' }}>
|
<div className="form-grid" style={{ marginTop: '0.5rem' }}>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="label">Sire (Father)</label>
|
<label className="label">Sire (Father)</label>
|
||||||
<select
|
<select name="sire_id" className="input"
|
||||||
name="sire_id"
|
value={formData.sire_id || ''} onChange={handleChange}>
|
||||||
className="input"
|
|
||||||
value={formData.sire_id || ''}
|
|
||||||
onChange={handleChange}
|
|
||||||
>
|
|
||||||
<option value="">Unknown</option>
|
<option value="">Unknown</option>
|
||||||
{males.map(d => (
|
{males.map(d => <option key={d.id} value={d.id}>{d.name}{d.is_champion ? ' ✪' : ''}</option>)}
|
||||||
<option key={d.id} value={d.id}>{d.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="label">Dam (Mother)</label>
|
<label className="label">Dam (Mother)</label>
|
||||||
<select
|
<select name="dam_id" className="input"
|
||||||
name="dam_id"
|
value={formData.dam_id || ''} onChange={handleChange}>
|
||||||
className="input"
|
|
||||||
value={formData.dam_id || ''}
|
|
||||||
onChange={handleChange}
|
|
||||||
>
|
|
||||||
<option value="">Unknown</option>
|
<option value="">Unknown</option>
|
||||||
{females.map(d => (
|
{females.map(d => <option key={d.id} value={d.id}>{d.name}{d.is_champion ? ' ✪' : ''}</option>)}
|
||||||
<option key={d.id} value={d.id}>{d.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -320,19 +287,12 @@ function DogForm({ dog, onClose, onSave }) {
|
|||||||
|
|
||||||
<div className="form-group" style={{ marginTop: '1rem' }}>
|
<div className="form-group" style={{ marginTop: '1rem' }}>
|
||||||
<label className="label">Notes</label>
|
<label className="label">Notes</label>
|
||||||
<textarea
|
<textarea name="notes" className="input" rows="4"
|
||||||
name="notes"
|
value={formData.notes} onChange={handleChange} />
|
||||||
className="input"
|
|
||||||
rows="4"
|
|
||||||
value={formData.notes}
|
|
||||||
onChange={handleChange}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="modal-footer">
|
<div className="modal-footer">
|
||||||
<button type="button" className="btn btn-secondary" onClick={onClose} disabled={loading}>
|
<button type="button" className="btn btn-secondary" onClick={onClose} disabled={loading}>Cancel</button>
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit" className="btn btn-primary" disabled={loading}>
|
<button type="submit" className="btn btn-primary" disabled={loading}>
|
||||||
{loading ? 'Saving...' : dog ? 'Update Dog' : 'Add Dog'}
|
{loading ? 'Saving...' : dog ? 'Update Dog' : 'Add Dog'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
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,36 +5,46 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* Modern dark color palette */
|
/* Primary accent: warm amber/copper to echo the gold-rust brand gradient */
|
||||||
--primary: #3b82f6;
|
--primary: #c2862a;
|
||||||
--primary-hover: #2563eb;
|
--primary-hover: #a86e1c;
|
||||||
--primary-light: #60a5fa;
|
--primary-light: #e0a84a;
|
||||||
--accent: #8b5cf6;
|
|
||||||
--success: #10b981;
|
/* Secondary/accent: deep copper-red for punch */
|
||||||
|
--accent: #9b3a10;
|
||||||
|
|
||||||
|
/* Status colors stay neutral/functional */
|
||||||
|
--success: #22c55e;
|
||||||
--danger: #ef4444;
|
--danger: #ef4444;
|
||||||
--warning: #f59e0b;
|
--warning: #f59e0b;
|
||||||
|
|
||||||
/* Dark theme */
|
/* Dark theme backgrounds — slightly warmer tones */
|
||||||
--bg-primary: #0f172a;
|
--bg-primary: #0e0f0c;
|
||||||
--bg-secondary: #1e293b;
|
--bg-secondary: #1a1a15;
|
||||||
--bg-tertiary: #334155;
|
--bg-tertiary: #2a2820;
|
||||||
--bg-elevated: #1e293b;
|
--bg-elevated: #222018;
|
||||||
|
|
||||||
/* Borders */
|
/* Borders — warm dark */
|
||||||
--border: #334155;
|
--border: #38352a;
|
||||||
--border-light: #475569;
|
--border-light: #524e3e;
|
||||||
|
|
||||||
/* Text */
|
/* Text */
|
||||||
--text-primary: #f1f5f9;
|
--text-primary: #f5f0e8;
|
||||||
--text-secondary: #cbd5e1;
|
--text-secondary: #ccc4b0;
|
||||||
--text-muted: #94a3b8;
|
--text-muted: #8c8472;
|
||||||
|
|
||||||
/* Shadows */
|
/* Shadows */
|
||||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
|
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.4);
|
||||||
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.4);
|
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.5);
|
||||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
|
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.6);
|
||||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.6);
|
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.7);
|
||||||
|
|
||||||
|
/* Champion badge colors */
|
||||||
|
--champion-gold: #d4a017;
|
||||||
|
--champion-glow: rgba(212, 160, 23, 0.25);
|
||||||
|
--bloodline-amber: #b06010;
|
||||||
|
--bloodline-glow: rgba(176, 96, 16, 0.2);
|
||||||
|
|
||||||
/* Misc */
|
/* Misc */
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
--radius-sm: 0.375rem;
|
--radius-sm: 0.375rem;
|
||||||
@@ -130,14 +140,15 @@ h3 { font-size: 1.25rem; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--primary);
|
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
|
||||||
color: white;
|
color: var(--bg-primary);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
.btn-primary:hover:not(:disabled) {
|
||||||
background: var(--primary-hover);
|
background: linear-gradient(135deg, var(--primary-light) 0%, var(--primary) 100%);
|
||||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
box-shadow: 0 4px 12px rgba(194, 134, 42, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
@@ -228,7 +239,7 @@ textarea:focus,
|
|||||||
select:focus {
|
select:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
box-shadow: 0 0 0 3px rgba(194, 134, 42, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input::placeholder {
|
.input::placeholder {
|
||||||
@@ -243,7 +254,7 @@ textarea {
|
|||||||
select {
|
select {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%2394a3b8' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
|
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%238c8472' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
|
||||||
background-position: right 0.5rem center;
|
background-position: right 0.5rem center;
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
background-size: 1.5em 1.5em;
|
background-size: 1.5em 1.5em;
|
||||||
@@ -308,15 +319,50 @@ select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.badge-primary {
|
.badge-primary {
|
||||||
background: rgba(59, 130, 246, 0.2);
|
background: rgba(194, 134, 42, 0.2);
|
||||||
color: var(--primary-light);
|
color: var(--primary-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-success {
|
.badge-success {
|
||||||
background: rgba(16, 185, 129, 0.2);
|
background: rgba(34, 197, 94, 0.2);
|
||||||
color: var(--success);
|
color: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Champion Badges */
|
||||||
|
.badge-champion {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: linear-gradient(135deg, rgba(212,160,23,0.25) 0%, rgba(155,58,16,0.2) 100%);
|
||||||
|
color: var(--champion-gold);
|
||||||
|
border: 1px solid rgba(212, 160, 23, 0.45);
|
||||||
|
box-shadow: 0 0 6px var(--champion-glow);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-bloodline {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: linear-gradient(135deg, rgba(176,96,16,0.2) 0%, rgba(139,37,0,0.15) 100%);
|
||||||
|
color: var(--bloodline-amber);
|
||||||
|
border: 1px solid rgba(176, 96, 16, 0.4);
|
||||||
|
box-shadow: 0 0 6px var(--bloodline-glow);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* Modal */
|
/* Modal */
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -324,7 +370,7 @@ select {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: rgba(0, 0, 0, 0.75);
|
background: rgba(0, 0, 0, 0.8);
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -475,9 +521,9 @@ select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.risk-low {
|
.risk-low {
|
||||||
background: rgba(16, 185, 129, 0.15);
|
background: rgba(34, 197, 94, 0.15);
|
||||||
color: var(--success);
|
color: var(--success);
|
||||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.risk-med {
|
.risk-med {
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import React from 'react'
|
import { StrictMode } from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { SettingsProvider } from './hooks/useSettings'
|
||||||
import App from './App.jsx'
|
import App from './App.jsx'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
createRoot(document.getElementById('root')).render(
|
||||||
<React.StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<SettingsProvider>
|
||||||
</React.StrictMode>,
|
<App />
|
||||||
)
|
</SettingsProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useParams, Link, useNavigate } from 'react-router-dom'
|
|||||||
import { Dog, GitBranch, Edit, Upload, Trash2, ArrowLeft, Calendar, Hash, Award } from 'lucide-react'
|
import { Dog, GitBranch, Edit, Upload, Trash2, ArrowLeft, Calendar, Hash, Award } from 'lucide-react'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import DogForm from '../components/DogForm'
|
import DogForm from '../components/DogForm'
|
||||||
|
import { ChampionBadge, ChampionBloodlineBadge } from '../components/ChampionBadge'
|
||||||
|
|
||||||
function DogDetail() {
|
function DogDetail() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
@@ -14,9 +15,7 @@ function DogDetail() {
|
|||||||
const [selectedPhoto, setSelectedPhoto] = useState(0)
|
const [selectedPhoto, setSelectedPhoto] = useState(0)
|
||||||
const fileInputRef = useRef(null)
|
const fileInputRef = useRef(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { fetchDog() }, [id])
|
||||||
fetchDog()
|
|
||||||
}, [id])
|
|
||||||
|
|
||||||
const fetchDog = async () => {
|
const fetchDog = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -32,11 +31,9 @@ function DogDetail() {
|
|||||||
const handlePhotoUpload = async (e) => {
|
const handlePhotoUpload = async (e) => {
|
||||||
const file = e.target.files[0]
|
const file = e.target.files[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
|
||||||
setUploading(true)
|
setUploading(true)
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('photo', file)
|
formData.append('photo', file)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.post(`/api/dogs/${id}/photos`, formData, {
|
await axios.post(`/api/dogs/${id}/photos`, formData, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' }
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
@@ -53,7 +50,6 @@ function DogDetail() {
|
|||||||
|
|
||||||
const handleDeletePhoto = async (photoIndex) => {
|
const handleDeletePhoto = async (photoIndex) => {
|
||||||
if (!confirm('Delete this photo?')) return
|
if (!confirm('Delete this photo?')) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.delete(`/api/dogs/${id}/photos/${photoIndex}`)
|
await axios.delete(`/api/dogs/${id}/photos/${photoIndex}`)
|
||||||
fetchDog()
|
fetchDog()
|
||||||
@@ -72,24 +68,20 @@ function DogDetail() {
|
|||||||
const birth = new Date(birthDate)
|
const birth = new Date(birthDate)
|
||||||
let years = today.getFullYear() - birth.getFullYear()
|
let years = today.getFullYear() - birth.getFullYear()
|
||||||
let months = today.getMonth() - birth.getMonth()
|
let months = today.getMonth() - birth.getMonth()
|
||||||
|
if (months < 0) { years--; months += 12 }
|
||||||
if (months < 0) {
|
|
||||||
years--
|
|
||||||
months += 12
|
|
||||||
}
|
|
||||||
|
|
||||||
if (years === 0) return `${months} month${months !== 1 ? 's' : ''}`
|
if (years === 0) return `${months} month${months !== 1 ? 's' : ''}`
|
||||||
if (months === 0) return `${years} year${years !== 1 ? 's' : ''}`
|
if (months === 0) return `${years} year${years !== 1 ? 's' : ''}`
|
||||||
return `${years}y ${months}m`
|
return `${years}y ${months}m`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
const hasChampionBlood = (d) =>
|
||||||
return <div className="container loading">Loading...</div>
|
(d.sire && d.sire.is_champion) || (d.dam && d.dam.is_champion)
|
||||||
}
|
|
||||||
|
|
||||||
if (!dog) {
|
if (loading) return <div className="container loading">Loading...</div>
|
||||||
return <div className="container">Dog not found</div>
|
if (!dog) return <div className="container">Dog not found</div>
|
||||||
}
|
|
||||||
|
const isChampion = !!dog.is_champion
|
||||||
|
const hasBloodline = !isChampion && hasChampionBlood(dog)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container" style={{ paddingTop: '2rem', paddingBottom: '3rem' }}>
|
<div className="container" style={{ paddingTop: '2rem', paddingBottom: '3rem' }}>
|
||||||
@@ -99,14 +91,18 @@ function DogDetail() {
|
|||||||
<ArrowLeft size={20} />
|
<ArrowLeft size={20} />
|
||||||
</button>
|
</button>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<h1 style={{ marginBottom: '0.25rem' }}>{dog.name}</h1>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem', flexWrap: 'wrap', marginBottom: '0.25rem' }}>
|
||||||
|
<h1 style={{ margin: 0 }}>{dog.name}</h1>
|
||||||
|
{isChampion && <ChampionBadge size="lg" />}
|
||||||
|
{hasBloodline && <ChampionBloodlineBadge size="lg" />}
|
||||||
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', color: 'var(--text-secondary)' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', color: 'var(--text-secondary)' }}>
|
||||||
<span>{dog.breed}</span>
|
<span>{dog.breed}</span>
|
||||||
<span>•</span>
|
<span>·</span>
|
||||||
<span>{dog.sex === 'male' ? 'Male ♂' : 'Female ♀'}</span>
|
<span>{dog.sex === 'male' ? 'Male ♂' : 'Female ♀'}</span>
|
||||||
{dog.birth_date && (
|
{dog.birth_date && (
|
||||||
<>
|
<>
|
||||||
<span>•</span>
|
<span>·</span>
|
||||||
<span>{calculateAge(dog.birth_date)}</span>
|
<span>{calculateAge(dog.birth_date)}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -125,12 +121,12 @@ function DogDetail() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: '1.5rem', marginBottom: '1.5rem' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: '1.5rem', marginBottom: '1.5rem' }}>
|
||||||
{/* Photo Section - Compact */}
|
{/* Photo Section */}
|
||||||
<div className="card" style={{ padding: '1rem' }}>
|
<div className="card" style={{ padding: '1rem' }}>
|
||||||
<div style={{ marginBottom: '0.75rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div style={{ marginBottom: '0.75rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<h3 style={{ fontSize: '0.875rem', textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)' }}>Photos</h3>
|
<h3 style={{ fontSize: '0.875rem', textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)' }}>Photos</h3>
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
disabled={uploading}
|
disabled={uploading}
|
||||||
style={{ padding: '0.375rem 0.75rem', fontSize: '0.75rem' }}
|
style={{ padding: '0.375rem 0.75rem', fontSize: '0.75rem' }}
|
||||||
@@ -138,46 +134,42 @@ function DogDetail() {
|
|||||||
<Upload size={14} />
|
<Upload size={14} />
|
||||||
{uploading ? 'Uploading...' : 'Add'}
|
{uploading ? 'Uploading...' : 'Add'}
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input ref={fileInputRef} type="file" accept="image/*" onChange={handlePhotoUpload} style={{ display: 'none' }} />
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={handlePhotoUpload}
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{dog.photo_urls && dog.photo_urls.length > 0 ? (
|
{dog.photo_urls && dog.photo_urls.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
{/* Main Photo */}
|
|
||||||
<div style={{ position: 'relative', marginBottom: '0.75rem' }}>
|
<div style={{ position: 'relative', marginBottom: '0.75rem' }}>
|
||||||
<img
|
<img
|
||||||
src={dog.photo_urls[selectedPhoto]}
|
src={dog.photo_urls[selectedPhoto]}
|
||||||
alt={dog.name}
|
alt={dog.name}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%', aspectRatio: '1', objectFit: 'cover',
|
||||||
aspectRatio: '1',
|
|
||||||
objectFit: 'cover',
|
|
||||||
borderRadius: 'var(--radius)',
|
borderRadius: 'var(--radius)',
|
||||||
border: '1px solid var(--border)'
|
border: isChampion
|
||||||
}}
|
? '2px solid var(--champion-gold)'
|
||||||
|
: hasBloodline
|
||||||
|
? '2px solid var(--bloodline-amber)'
|
||||||
|
: '1px solid var(--border)',
|
||||||
|
boxShadow: isChampion
|
||||||
|
? '0 0 12px var(--champion-glow)'
|
||||||
|
: hasBloodline
|
||||||
|
? '0 0 10px var(--bloodline-glow)'
|
||||||
|
: 'none'
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
className="btn-icon"
|
className="btn-icon"
|
||||||
onClick={() => handleDeletePhoto(selectedPhoto)}
|
onClick={() => handleDeletePhoto(selectedPhoto)}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute', top: '0.5rem', right: '0.5rem',
|
||||||
top: '0.5rem',
|
background: 'rgba(14, 15, 12, 0.8)',
|
||||||
right: '0.5rem',
|
|
||||||
background: 'rgba(15, 23, 42, 0.8)',
|
|
||||||
backdropFilter: 'blur(8px)'
|
backdropFilter: 'blur(8px)'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 size={16} color="var(--danger)" />
|
<Trash2 size={16} color="var(--danger)" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Thumbnail Strip */}
|
|
||||||
{dog.photo_urls.length > 1 && (
|
{dog.photo_urls.length > 1 && (
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', overflowX: 'auto' }}>
|
<div style={{ display: 'flex', gap: '0.5rem', overflowX: 'auto' }}>
|
||||||
{dog.photo_urls.map((url, index) => (
|
{dog.photo_urls.map((url, index) => (
|
||||||
@@ -187,9 +179,7 @@ function DogDetail() {
|
|||||||
alt={`${dog.name} ${index + 1}`}
|
alt={`${dog.name} ${index + 1}`}
|
||||||
onClick={() => setSelectedPhoto(index)}
|
onClick={() => setSelectedPhoto(index)}
|
||||||
style={{
|
style={{
|
||||||
width: '60px',
|
width: '60px', height: '60px', objectFit: 'cover',
|
||||||
height: '60px',
|
|
||||||
objectFit: 'cover',
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
borderRadius: 'var(--radius-sm)',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
border: selectedPhoto === index ? '2px solid var(--primary)' : '1px solid var(--border)',
|
border: selectedPhoto === index ? '2px solid var(--primary)' : '1px solid var(--border)',
|
||||||
@@ -213,18 +203,26 @@ function DogDetail() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
||||||
<h2 style={{ fontSize: '1rem', marginBottom: '1rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Details</h2>
|
<h2 style={{ fontSize: '1rem', marginBottom: '1rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Details</h2>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="info-row">
|
<div className="info-row">
|
||||||
<span className="info-label">Breed</span>
|
<span className="info-label">Breed</span>
|
||||||
<span className="info-value">{dog.breed}</span>
|
<span className="info-value">{dog.breed}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="info-row">
|
<div className="info-row">
|
||||||
<span className="info-label">Sex</span>
|
<span className="info-label">Sex</span>
|
||||||
<span className="info-value">{dog.sex === 'male' ? 'Male ♂' : 'Female ♀'}</span>
|
<span className="info-value">{dog.sex === 'male' ? 'Male ♂' : 'Female ♀'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="info-row">
|
||||||
|
<span className="info-label">Champion</span>
|
||||||
|
<span className="info-value">
|
||||||
|
{isChampion
|
||||||
|
? <ChampionBadge size="lg" />
|
||||||
|
: hasBloodline
|
||||||
|
? <ChampionBloodlineBadge size="lg" />
|
||||||
|
: <span style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>—</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{dog.birth_date && (
|
{dog.birth_date && (
|
||||||
<div className="info-row">
|
<div className="info-row">
|
||||||
<span className="info-label"><Calendar size={14} style={{ display: 'inline', marginRight: '0.25rem' }} />Birth Date</span>
|
<span className="info-label"><Calendar size={14} style={{ display: 'inline', marginRight: '0.25rem' }} />Birth Date</span>
|
||||||
@@ -234,21 +232,18 @@ function DogDetail() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{dog.color && (
|
{dog.color && (
|
||||||
<div className="info-row">
|
<div className="info-row">
|
||||||
<span className="info-label">Color</span>
|
<span className="info-label">Color</span>
|
||||||
<span className="info-value">{dog.color}</span>
|
<span className="info-value">{dog.color}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{dog.registration_number && (
|
{dog.registration_number && (
|
||||||
<div className="info-row">
|
<div className="info-row">
|
||||||
<span className="info-label"><Award size={14} style={{ display: 'inline', marginRight: '0.25rem' }} />Registration</span>
|
<span className="info-label"><Award size={14} style={{ display: 'inline', marginRight: '0.25rem' }} />Registration</span>
|
||||||
<span className="info-value" style={{ fontFamily: 'monospace' }}>{dog.registration_number}</span>
|
<span className="info-value" style={{ fontFamily: 'monospace' }}>{dog.registration_number}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{dog.microchip && (
|
{dog.microchip && (
|
||||||
<div className="info-row">
|
<div className="info-row">
|
||||||
<span className="info-label"><Hash size={14} style={{ display: 'inline', marginRight: '0.25rem' }} />Microchip</span>
|
<span className="info-label"><Hash size={14} style={{ display: 'inline', marginRight: '0.25rem' }} />Microchip</span>
|
||||||
@@ -265,9 +260,12 @@ function DogDetail() {
|
|||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: '0.8125rem', color: 'var(--text-muted)', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Sire</div>
|
<div style={{ fontSize: '0.8125rem', color: 'var(--text-muted)', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Sire</div>
|
||||||
{dog.sire ? (
|
{dog.sire ? (
|
||||||
<Link to={`/dogs/${dog.sire.id}`} style={{ color: 'var(--primary)', fontWeight: 500, textDecoration: 'none' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', flexWrap: 'wrap' }}>
|
||||||
{dog.sire.name}
|
<Link to={`/dogs/${dog.sire.id}`} style={{ color: 'var(--primary)', fontWeight: 500, textDecoration: 'none' }}>
|
||||||
</Link>
|
{dog.sire.name}
|
||||||
|
</Link>
|
||||||
|
{dog.sire.is_champion && <ChampionBadge />}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>Unknown</span>
|
<span style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>Unknown</span>
|
||||||
)}
|
)}
|
||||||
@@ -275,9 +273,12 @@ function DogDetail() {
|
|||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: '0.8125rem', color: 'var(--text-muted)', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Dam</div>
|
<div style={{ fontSize: '0.8125rem', color: 'var(--text-muted)', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Dam</div>
|
||||||
{dog.dam ? (
|
{dog.dam ? (
|
||||||
<Link to={`/dogs/${dog.dam.id}`} style={{ color: 'var(--primary)', fontWeight: 500, textDecoration: 'none' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', flexWrap: 'wrap' }}>
|
||||||
{dog.dam.name}
|
<Link to={`/dogs/${dog.dam.id}`} style={{ color: 'var(--primary)', fontWeight: 500, textDecoration: 'none' }}>
|
||||||
</Link>
|
{dog.dam.name}
|
||||||
|
</Link>
|
||||||
|
{dog.dam.is_champion && <ChampionBadge />}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>Unknown</span>
|
<span style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>Unknown</span>
|
||||||
)}
|
)}
|
||||||
@@ -301,19 +302,20 @@ function DogDetail() {
|
|||||||
<h2 style={{ fontSize: '1rem', marginBottom: '1rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Offspring ({dog.offspring.length})</h2>
|
<h2 style={{ fontSize: '1rem', marginBottom: '1rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Offspring ({dog.offspring.length})</h2>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: '0.75rem' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: '0.75rem' }}>
|
||||||
{dog.offspring.map(child => (
|
{dog.offspring.map(child => (
|
||||||
<Link
|
<Link
|
||||||
key={child.id}
|
key={child.id}
|
||||||
to={`/dogs/${child.id}`}
|
to={`/dogs/${child.id}`}
|
||||||
style={{
|
style={{
|
||||||
padding: '0.75rem 1rem',
|
padding: '0.75rem 1rem',
|
||||||
background: 'var(--bg-primary)',
|
background: 'var(--bg-primary)',
|
||||||
border: '1px solid var(--border)',
|
border: '1px solid var(--border)',
|
||||||
borderRadius: 'var(--radius-sm)',
|
borderRadius: 'var(--radius-sm)',
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
transition: 'var(--transition)',
|
transition: 'var(--transition)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center'
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem'
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.borderColor = 'var(--primary)'
|
e.currentTarget.style.borderColor = 'var(--primary)'
|
||||||
@@ -325,7 +327,10 @@ function DogDetail() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ color: 'var(--text-primary)', fontWeight: 500 }}>{child.name}</span>
|
<span style={{ color: 'var(--text-primary)', fontWeight: 500 }}>{child.name}</span>
|
||||||
<span style={{ fontSize: '1.125rem' }}>{child.sex === 'male' ? '♂' : '♀'}</span>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
|
||||||
|
{child.is_champion && <ChampionBadge />}
|
||||||
|
<span style={{ fontSize: '1.125rem' }}>{child.sex === 'male' ? '♂' : '♀'}</span>
|
||||||
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -336,14 +341,11 @@ function DogDetail() {
|
|||||||
<DogForm
|
<DogForm
|
||||||
dog={dog}
|
dog={dog}
|
||||||
onClose={() => setShowEditModal(false)}
|
onClose={() => setShowEditModal(false)}
|
||||||
onSave={() => {
|
onSave={() => { fetchDog(); setShowEditModal(false) }}
|
||||||
fetchDog()
|
|
||||||
setShowEditModal(false)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default DogDetail
|
export default DogDetail
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'
|
|||||||
import { Dog, Plus, Search, Calendar, Hash, ArrowRight } from 'lucide-react'
|
import { Dog, Plus, Search, Calendar, Hash, ArrowRight } from 'lucide-react'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import DogForm from '../components/DogForm'
|
import DogForm from '../components/DogForm'
|
||||||
|
import { ChampionBadge, ChampionBloodlineBadge } from '../components/ChampionBadge'
|
||||||
|
|
||||||
function DogList() {
|
function DogList() {
|
||||||
const [dogs, setDogs] = useState([])
|
const [dogs, setDogs] = useState([])
|
||||||
@@ -12,13 +13,8 @@ function DogList() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showAddModal, setShowAddModal] = useState(false)
|
const [showAddModal, setShowAddModal] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { fetchDogs() }, [])
|
||||||
fetchDogs()
|
useEffect(() => { filterDogs() }, [dogs, search, sexFilter])
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
filterDogs()
|
|
||||||
}, [dogs, search, sexFilter])
|
|
||||||
|
|
||||||
const fetchDogs = async () => {
|
const fetchDogs = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -33,24 +29,19 @@ function DogList() {
|
|||||||
|
|
||||||
const filterDogs = () => {
|
const filterDogs = () => {
|
||||||
let filtered = dogs
|
let filtered = dogs
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
filtered = filtered.filter(dog =>
|
filtered = filtered.filter(dog =>
|
||||||
dog.name.toLowerCase().includes(search.toLowerCase()) ||
|
dog.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
(dog.registration_number && dog.registration_number.toLowerCase().includes(search.toLowerCase()))
|
(dog.registration_number && dog.registration_number.toLowerCase().includes(search.toLowerCase()))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sexFilter !== 'all') {
|
if (sexFilter !== 'all') {
|
||||||
filtered = filtered.filter(dog => dog.sex === sexFilter)
|
filtered = filtered.filter(dog => dog.sex === sexFilter)
|
||||||
}
|
}
|
||||||
|
|
||||||
setFilteredDogs(filtered)
|
setFilteredDogs(filtered)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => { fetchDogs() }
|
||||||
fetchDogs()
|
|
||||||
}
|
|
||||||
|
|
||||||
const calculateAge = (birthDate) => {
|
const calculateAge = (birthDate) => {
|
||||||
if (!birthDate) return null
|
if (!birthDate) return null
|
||||||
@@ -58,17 +49,16 @@ function DogList() {
|
|||||||
const birth = new Date(birthDate)
|
const birth = new Date(birthDate)
|
||||||
let years = today.getFullYear() - birth.getFullYear()
|
let years = today.getFullYear() - birth.getFullYear()
|
||||||
let months = today.getMonth() - birth.getMonth()
|
let months = today.getMonth() - birth.getMonth()
|
||||||
|
if (months < 0) { years--; months += 12 }
|
||||||
if (months < 0) {
|
|
||||||
years--
|
|
||||||
months += 12
|
|
||||||
}
|
|
||||||
|
|
||||||
if (years === 0) return `${months}mo`
|
if (years === 0) return `${months}mo`
|
||||||
if (months === 0) return `${years}y`
|
if (months === 0) return `${years}y`
|
||||||
return `${years}y ${months}mo`
|
return `${years}y ${months}mo`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A dog has champion blood if sire or dam is a champion
|
||||||
|
const hasChampionBlood = (dog) =>
|
||||||
|
(dog.sire && dog.sire.is_champion) || (dog.dam && dog.dam.is_champion)
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="container loading">Loading dogs...</div>
|
return <div className="container loading">Loading dogs...</div>
|
||||||
}
|
}
|
||||||
@@ -109,12 +99,9 @@ function DogList() {
|
|||||||
<option value="female">Females ♀</option>
|
<option value="female">Females ♀</option>
|
||||||
</select>
|
</select>
|
||||||
{(search || sexFilter !== 'all') && (
|
{(search || sexFilter !== 'all') && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
onClick={() => {
|
onClick={() => { setSearch(''); setSexFilter('all') }}
|
||||||
setSearch('')
|
|
||||||
setSexFilter('all')
|
|
||||||
}}
|
|
||||||
style={{ padding: '0.625rem 1rem', fontSize: '0.875rem' }}
|
style={{ padding: '0.625rem 1rem', fontSize: '0.875rem' }}
|
||||||
>
|
>
|
||||||
Clear
|
Clear
|
||||||
@@ -131,8 +118,8 @@ function DogList() {
|
|||||||
{search || sexFilter !== 'all' ? 'No dogs found' : 'No dogs yet'}
|
{search || sexFilter !== 'all' ? 'No dogs found' : 'No dogs yet'}
|
||||||
</h3>
|
</h3>
|
||||||
<p style={{ color: 'var(--text-secondary)', marginBottom: '2rem' }}>
|
<p style={{ color: 'var(--text-secondary)', marginBottom: '2rem' }}>
|
||||||
{search || sexFilter !== 'all'
|
{search || sexFilter !== 'all'
|
||||||
? 'Try adjusting your search or filters'
|
? 'Try adjusting your search or filters'
|
||||||
: 'Add your first dog to get started'}
|
: 'Add your first dog to get started'}
|
||||||
</p>
|
</p>
|
||||||
{!search && sexFilter === 'all' && (
|
{!search && sexFilter === 'all' && (
|
||||||
@@ -145,13 +132,13 @@ function DogList() {
|
|||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'grid', gap: '1rem' }}>
|
<div style={{ display: 'grid', gap: '1rem' }}>
|
||||||
{filteredDogs.map(dog => (
|
{filteredDogs.map(dog => (
|
||||||
<Link
|
<Link
|
||||||
key={dog.id}
|
key={dog.id}
|
||||||
to={`/dogs/${dog.id}`}
|
to={`/dogs/${dog.id}`}
|
||||||
className="card"
|
className="card"
|
||||||
style={{
|
style={{
|
||||||
padding: '1rem',
|
padding: '1rem',
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: '1rem',
|
gap: '1rem',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -169,65 +156,60 @@ function DogList() {
|
|||||||
e.currentTarget.style.boxShadow = 'var(--shadow-sm)'
|
e.currentTarget.style.boxShadow = 'var(--shadow-sm)'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Avatar Photo */}
|
{/* Avatar */}
|
||||||
<div style={{
|
<div style={{
|
||||||
width: '80px',
|
width: '80px', height: '80px', flexShrink: 0,
|
||||||
height: '80px',
|
|
||||||
flexShrink: 0,
|
|
||||||
borderRadius: 'var(--radius)',
|
borderRadius: 'var(--radius)',
|
||||||
background: 'var(--bg-primary)',
|
background: 'var(--bg-primary)',
|
||||||
border: '2px solid var(--border)',
|
border: dog.is_champion
|
||||||
display: 'flex',
|
? '2px solid var(--champion-gold)'
|
||||||
alignItems: 'center',
|
: hasChampionBlood(dog)
|
||||||
justifyContent: 'center',
|
? '2px solid var(--bloodline-amber)'
|
||||||
overflow: 'hidden'
|
: '2px solid var(--border)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
boxShadow: dog.is_champion
|
||||||
|
? '0 0 8px var(--champion-glow)'
|
||||||
|
: hasChampionBlood(dog)
|
||||||
|
? '0 0 8px var(--bloodline-glow)'
|
||||||
|
: 'none'
|
||||||
}}>
|
}}>
|
||||||
{dog.photo_urls && dog.photo_urls.length > 0 ? (
|
{dog.photo_urls && dog.photo_urls.length > 0 ? (
|
||||||
<img
|
<img
|
||||||
src={dog.photo_urls[0]}
|
src={dog.photo_urls[0]}
|
||||||
alt={dog.name}
|
alt={dog.name}
|
||||||
style={{
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
objectFit: 'cover'
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Dog size={32} style={{ color: 'var(--text-muted)', opacity: 0.5 }} />
|
<Dog size={32} style={{ color: 'var(--text-muted)', opacity: 0.5 }} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info Section */}
|
{/* Info */}
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<h3 style={{
|
<h3 style={{
|
||||||
fontSize: '1.125rem',
|
fontSize: '1.125rem',
|
||||||
marginBottom: '0.375rem',
|
marginBottom: '0.25rem',
|
||||||
overflow: 'hidden',
|
display: 'flex', alignItems: 'center', gap: '0.5rem',
|
||||||
textOverflow: 'ellipsis',
|
flexWrap: 'wrap'
|
||||||
whiteSpace: 'nowrap'
|
|
||||||
}}>
|
}}>
|
||||||
{dog.name}
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
<span style={{
|
{dog.name}
|
||||||
marginLeft: '0.5rem',
|
</span>
|
||||||
fontSize: '1rem',
|
<span style={{ color: dog.sex === 'male' ? 'var(--primary)' : '#ec4899', fontSize: '1rem' }}>
|
||||||
color: dog.sex === 'male' ? 'var(--primary)' : '#ec4899'
|
|
||||||
}}>
|
|
||||||
{dog.sex === 'male' ? '♂' : '♀'}
|
{dog.sex === 'male' ? '♂' : '♀'}
|
||||||
</span>
|
</span>
|
||||||
|
{dog.is_champion ? <ChampionBadge /> : hasChampionBlood(dog) ? <ChampionBloodlineBadge /> : null}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex',
|
display: 'flex', flexWrap: 'wrap', gap: '0.75rem',
|
||||||
flexWrap: 'wrap',
|
fontSize: '0.8125rem', color: 'var(--text-secondary)', marginBottom: '0.5rem'
|
||||||
gap: '0.75rem',
|
|
||||||
fontSize: '0.8125rem',
|
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
marginBottom: '0.5rem'
|
|
||||||
}}>
|
}}>
|
||||||
<span>{dog.breed}</span>
|
<span>{dog.breed}</span>
|
||||||
{dog.birth_date && (
|
{dog.birth_date && (
|
||||||
<>
|
<>
|
||||||
<span>•</span>
|
<span>·</span>
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
<span style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||||
<Calendar size={12} />
|
<Calendar size={12} />
|
||||||
{calculateAge(dog.birth_date)}
|
{calculateAge(dog.birth_date)}
|
||||||
@@ -236,23 +218,20 @@ function DogList() {
|
|||||||
)}
|
)}
|
||||||
{dog.color && (
|
{dog.color && (
|
||||||
<>
|
<>
|
||||||
<span>•</span>
|
<span>·</span>
|
||||||
<span>{dog.color}</span>
|
<span>{dog.color}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{dog.registration_number && (
|
{dog.registration_number && (
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'inline-flex',
|
display: 'inline-flex', alignItems: 'center', gap: '0.25rem',
|
||||||
alignItems: 'center',
|
|
||||||
gap: '0.25rem',
|
|
||||||
padding: '0.25rem 0.5rem',
|
padding: '0.25rem 0.5rem',
|
||||||
background: 'var(--bg-primary)',
|
background: 'var(--bg-primary)',
|
||||||
border: '1px solid var(--border)',
|
border: '1px solid var(--border)',
|
||||||
borderRadius: 'var(--radius-sm)',
|
borderRadius: 'var(--radius-sm)',
|
||||||
fontSize: '0.75rem',
|
fontSize: '0.75rem', fontFamily: 'monospace',
|
||||||
fontFamily: 'monospace',
|
|
||||||
color: 'var(--text-muted)'
|
color: 'var(--text-muted)'
|
||||||
}}>
|
}}>
|
||||||
<Hash size={10} />
|
<Hash size={10} />
|
||||||
@@ -261,11 +240,7 @@ function DogList() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Arrow Indicator */}
|
<div style={{ opacity: 0.5, transition: 'var(--transition)' }}>
|
||||||
<div style={{
|
|
||||||
opacity: 0.5,
|
|
||||||
transition: 'var(--transition)'
|
|
||||||
}}>
|
|
||||||
<ArrowRight size={20} color="var(--text-muted)" />
|
<ArrowRight size={20} color="var(--text-muted)" />
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -283,4 +258,4 @@ function DogList() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default DogList
|
export default DogList
|
||||||
|
|||||||
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 path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|
||||||
function initDatabase(dbPath) {
|
const dbPath = path.join(__dirname, '../../data');
|
||||||
// Ensure data directory exists
|
const db = new Database(path.join(dbPath, 'breedr.db'));
|
||||||
const dir = path.dirname(dbPath);
|
|
||||||
if (!fs.existsSync(dir)) {
|
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = new Database(dbPath);
|
|
||||||
|
|
||||||
// Enable foreign keys
|
|
||||||
db.pragma('foreign_keys = ON');
|
|
||||||
|
|
||||||
console.log('Initializing database schema...');
|
|
||||||
|
|
||||||
// Dogs table - NO sire/dam columns, only litter_id
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS dogs (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
registration_number TEXT UNIQUE,
|
|
||||||
breed TEXT NOT NULL,
|
|
||||||
sex TEXT NOT NULL CHECK(sex IN ('male', 'female')),
|
|
||||||
birth_date DATE,
|
|
||||||
color TEXT,
|
|
||||||
microchip TEXT,
|
|
||||||
photo_urls TEXT, -- JSON array of photo URLs
|
|
||||||
notes TEXT,
|
|
||||||
litter_id INTEGER,
|
|
||||||
is_active INTEGER DEFAULT 1,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (litter_id) REFERENCES litters(id) ON DELETE SET NULL
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Create unique index for microchip that allows NULL values
|
|
||||||
db.exec(`
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_dogs_microchip
|
|
||||||
ON dogs(microchip)
|
|
||||||
WHERE microchip IS NOT NULL
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Parents table - Stores sire/dam relationships
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS parents (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
dog_id INTEGER NOT NULL,
|
|
||||||
parent_id INTEGER NOT NULL,
|
|
||||||
parent_type TEXT NOT NULL CHECK(parent_type IN ('sire', 'dam')),
|
|
||||||
FOREIGN KEY (dog_id) REFERENCES dogs(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (parent_id) REFERENCES dogs(id) ON DELETE CASCADE,
|
|
||||||
UNIQUE(dog_id, parent_type)
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Litters table - Breeding records
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS litters (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
sire_id INTEGER NOT NULL,
|
|
||||||
dam_id INTEGER NOT NULL,
|
|
||||||
breeding_date DATE NOT NULL,
|
|
||||||
whelping_date DATE,
|
|
||||||
puppy_count INTEGER DEFAULT 0,
|
|
||||||
notes TEXT,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (sire_id) REFERENCES dogs(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (dam_id) REFERENCES dogs(id) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Health records table
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS health_records (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
dog_id INTEGER NOT NULL,
|
|
||||||
record_type TEXT NOT NULL CHECK(record_type IN ('test', 'vaccination', 'exam', 'treatment', 'certification')),
|
|
||||||
test_name TEXT,
|
|
||||||
test_date DATE NOT NULL,
|
|
||||||
result TEXT,
|
|
||||||
document_url TEXT,
|
|
||||||
notes TEXT,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (dog_id) REFERENCES dogs(id) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Heat cycles table
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS heat_cycles (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
dog_id INTEGER NOT NULL,
|
|
||||||
start_date DATE NOT NULL,
|
|
||||||
end_date DATE,
|
|
||||||
progesterone_peak_date DATE,
|
|
||||||
breeding_date DATE,
|
|
||||||
breeding_successful INTEGER DEFAULT 0,
|
|
||||||
notes TEXT,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (dog_id) REFERENCES dogs(id) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Traits table - Genetic trait tracking
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS traits (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
dog_id INTEGER NOT NULL,
|
|
||||||
trait_category TEXT NOT NULL,
|
|
||||||
trait_name TEXT NOT NULL,
|
|
||||||
trait_value TEXT NOT NULL,
|
|
||||||
inherited_from INTEGER,
|
|
||||||
notes TEXT,
|
|
||||||
FOREIGN KEY (dog_id) REFERENCES dogs(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (inherited_from) REFERENCES dogs(id) ON DELETE SET NULL
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Create indexes for performance
|
|
||||||
db.exec(`
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_dogs_name ON dogs(name);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_dogs_registration ON dogs(registration_number);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_dogs_litter ON dogs(litter_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_parents_dog ON parents(dog_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_parents_parent ON parents(parent_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_litters_sire ON litters(sire_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_litters_dam ON litters(dam_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_health_dog ON health_records(dog_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_heat_dog ON heat_cycles(dog_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_traits_dog ON traits(dog_id);
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Create trigger for updated_at
|
|
||||||
db.exec(`
|
|
||||||
CREATE TRIGGER IF NOT EXISTS update_dogs_timestamp
|
|
||||||
AFTER UPDATE ON dogs
|
|
||||||
FOR EACH ROW
|
|
||||||
BEGIN
|
|
||||||
UPDATE dogs SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
|
|
||||||
END;
|
|
||||||
`);
|
|
||||||
|
|
||||||
console.log('✓ Database schema initialized successfully!');
|
|
||||||
console.log('✓ Dogs table: NO sire/dam columns, uses parents table');
|
|
||||||
console.log('✓ Parents table: Stores sire/dam relationships');
|
|
||||||
console.log('✓ Litters table: Links puppies via litter_id');
|
|
||||||
|
|
||||||
db.close();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDatabase() {
|
function getDatabase() {
|
||||||
const dbPath = process.env.DB_PATH || path.join(__dirname, '../../data/breedr.db');
|
|
||||||
const db = new Database(dbPath);
|
|
||||||
db.pragma('foreign_keys = ON');
|
|
||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { initDatabase, getDatabase };
|
function initDatabase() {
|
||||||
|
db.pragma('foreign_keys = ON');
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
|
||||||
// Run initialization if called directly
|
// ── Dogs ────────────────────────────────────────────────────────────
|
||||||
if (require.main === module) {
|
db.exec(`
|
||||||
const dbPath = process.env.DB_PATH || path.join(__dirname, '../../data/breedr.db');
|
CREATE TABLE IF NOT EXISTS dogs (
|
||||||
console.log('\n==========================================');
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
console.log('BREEDR Database Initialization');
|
name TEXT NOT NULL,
|
||||||
console.log('==========================================');
|
registration_number TEXT,
|
||||||
console.log(`Database: ${dbPath}`);
|
breed TEXT NOT NULL,
|
||||||
console.log('==========================================\n');
|
sex TEXT NOT NULL CHECK(sex IN ('male', 'female')),
|
||||||
initDatabase(dbPath);
|
birth_date TEXT,
|
||||||
console.log('\n✓ Database ready!\n');
|
color TEXT,
|
||||||
|
microchip TEXT,
|
||||||
|
litter_id INTEGER,
|
||||||
|
is_active INTEGER DEFAULT 1,
|
||||||
|
is_champion INTEGER DEFAULT 0,
|
||||||
|
photo_urls TEXT DEFAULT '[]',
|
||||||
|
notes TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// migrate: add is_champion if missing (safe on existing DBs)
|
||||||
|
try {
|
||||||
|
db.exec(`ALTER TABLE dogs ADD COLUMN is_champion INTEGER DEFAULT 0`);
|
||||||
|
} catch (_) { /* column already exists */ }
|
||||||
|
|
||||||
|
// ── Parents ─────────────────────────────────────────────────────────
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS parents (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
dog_id INTEGER NOT NULL,
|
||||||
|
parent_id INTEGER NOT NULL,
|
||||||
|
parent_type TEXT NOT NULL CHECK(parent_type IN ('sire', 'dam')),
|
||||||
|
FOREIGN KEY (dog_id) REFERENCES dogs(id),
|
||||||
|
FOREIGN KEY (parent_id) REFERENCES dogs(id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ── Breeding Records ────────────────────────────────────────────────
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS breeding_records (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
sire_id INTEGER NOT NULL,
|
||||||
|
dam_id INTEGER NOT NULL,
|
||||||
|
breeding_date TEXT,
|
||||||
|
due_date TEXT,
|
||||||
|
conception_method TEXT CHECK(conception_method IN ('natural', 'ai', 'frozen', 'surgical')),
|
||||||
|
notes TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (sire_id) REFERENCES dogs(id),
|
||||||
|
FOREIGN KEY (dam_id) REFERENCES dogs(id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ── Litters ─────────────────────────────────────────────────────────
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS litters (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
breeding_id INTEGER,
|
||||||
|
sire_id INTEGER NOT NULL,
|
||||||
|
dam_id INTEGER NOT NULL,
|
||||||
|
whelp_date TEXT,
|
||||||
|
total_count INTEGER DEFAULT 0,
|
||||||
|
male_count INTEGER DEFAULT 0,
|
||||||
|
female_count INTEGER DEFAULT 0,
|
||||||
|
stillborn_count INTEGER DEFAULT 0,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (breeding_id) REFERENCES breeding_records(id),
|
||||||
|
FOREIGN KEY (sire_id) REFERENCES dogs(id),
|
||||||
|
FOREIGN KEY (dam_id) REFERENCES dogs(id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ── Health Records ──────────────────────────────────────────────────
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS health_records (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
dog_id INTEGER NOT NULL,
|
||||||
|
record_type TEXT NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
vet_name TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
result TEXT,
|
||||||
|
next_due TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (dog_id) REFERENCES dogs(id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ── Settings ─────────────────────────────────────────────────────────
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
kennel_name TEXT DEFAULT 'BREEDR',
|
||||||
|
kennel_tagline TEXT,
|
||||||
|
kennel_address TEXT,
|
||||||
|
kennel_phone TEXT,
|
||||||
|
kennel_email TEXT,
|
||||||
|
kennel_website TEXT,
|
||||||
|
kennel_akc_id TEXT,
|
||||||
|
kennel_breed TEXT,
|
||||||
|
owner_name TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// migrate: add new kennel columns if missing (safe on existing DBs)
|
||||||
|
const kennelCols = [
|
||||||
|
['kennel_name', "TEXT DEFAULT 'BREEDR'"],
|
||||||
|
['kennel_tagline', 'TEXT'],
|
||||||
|
['kennel_address', 'TEXT'],
|
||||||
|
['kennel_phone', 'TEXT'],
|
||||||
|
['kennel_email', 'TEXT'],
|
||||||
|
['kennel_website', 'TEXT'],
|
||||||
|
['kennel_akc_id', 'TEXT'],
|
||||||
|
['kennel_breed', 'TEXT'],
|
||||||
|
['owner_name', 'TEXT'],
|
||||||
|
];
|
||||||
|
for (const [col, def] of kennelCols) {
|
||||||
|
try {
|
||||||
|
db.exec(`ALTER TABLE settings ADD COLUMN ${col} ${def}`);
|
||||||
|
} catch (_) { /* already exists */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed a default settings row if none exists
|
||||||
|
const existing = db.prepare('SELECT id FROM settings LIMIT 1').get();
|
||||||
|
if (!existing) {
|
||||||
|
db.prepare(`INSERT INTO settings (kennel_name) VALUES (?)`).run('BREEDR');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✓ Database initialized successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
module.exports = { getDatabase, initDatabase };
|
||||||
|
|||||||
@@ -1,96 +1 @@
|
|||||||
const express = require('express');
|
Y29uc3QgZXhwcmVzcyA9IHJlcXVpcmUoJ2V4cHJlc3MnKTsKY29uc3QgY29ycyA9IHJlcXVpcmUoJ2NvcnMnKTsKY29uc3QgaGVsbWV0ID0gcmVxdWlyZSgnaGVsbWV0Jyk7CmNvbnN0IHBhdGggPSByZXF1aXJlKCdwYXRoJyk7CmNvbnN0IGZzID0gcmVxdWlyZSgnZnMnKTsKY29uc3QgeyBpbml0RGF0YWJhc2UgfSA9IHJlcXVpcmUoJy4vZGIvaW5pdCcpOwoKY29uc3QgYXBwID0gZXhwcmVzcygpOwpjb25zdCBQT1JUID0gcHJvY2Vzcy5lbnYuUE9SVCB8fCAzMDAwOwpjb25zdCBEQl9QQVRIID0gcHJvY2Vzcy5lbnYuREJfUEFUSCB8fCBwYXRoLmpvaW4oX19kaXJuYW1lLCAnLi4vZGF0YS9icmVlZHIuZGInKTsKY29uc3QgVVBMT0FEX1BBVEggPSBwcm9jZXNzLmVudi5VUExPQURfUEFUSCB8fCBwYXRoLmpvaW4oX19kaXJuYW1lLCAnLi4vdXBsb2FkcycpOwpjb25zdCBTVEFUSUNfUEFUSCA9IHByb2Nlc3MuZW52LlNUQVRJQ19QQVRIIHx8IHBhdGguam9pbihfX2Rpcm5hbWUsICcuLi9zdGF0aWMnKTsKCmNvbnN0IGRhdGFEaXIgPSBwYXRoLmRpcm5hbWUoREJfUEFUSCk7CmlmICghZnMuZXhpc3RzU3luYyhkYXRhRGlyKSkgZnMubWtkaXJTeW5jKGRhdGFEaXIsIHsgcmVjdXJzaXZlOiB0cnVlIH0pOwppZiAoIWZzLmV4aXN0c1N5bmMoVVBMT0FEX1BBVEgpKSBmcy5ta2RpclN5bmMoVVBMT0FEX1BBVEgsIHsgcmVjdXJzaXZlOiB0cnVlIH0pOwppZiAoIWZzLmV4aXN0c1N5bmMoU1RBVElDX1BBVEgpKSBmcy5ta2RpclN5bmMoU1RBVElDX1BBVEgsIHsgcmVjdXJzaXZlOiB0cnVlIH0pOwoKY29uc29sZS5sb2coJ0luaXRpYWxpemluZyBkYXRhYmFzZS4uLicpOwppbml0RGF0YWJhc2UoREJfUEFUSCk7CmNvbnNvbGUubG9nKCfinJMgRGF0YWJhc2UgcmVhZHkhXG4nKTsKCmFwcC51c2UoaGVsbWV0KHsgY29udGVudFNlY3VyaXR5UG9saWN5OiBmYWxzZSB9KSk7CmFwcC51c2UoY29ycygpKTsKYXBwLnVzZShleHByZXNzLmpzb24oKSk7CmFwcC51c2UoZXhwcmVzcy51cmxlbmNvZGVkKHsgZXh0ZW5kZWQ6IHRydWUgfSkpOwoKYXBwLnVzZSgnL3VwbG9hZHMnLCBleHByZXNzLnN0YXRpYyhVUExPQURfUEFUSCkpOwphcHAudXNlKCcvc3RhdGljJywgZXhwcmVzcy5zdGF0aWMoU1RBVElDX1BBVEgpKTsKYXBwLnVzZSgnL3VwbG9hZHMnLCAocmVxLCByZXMpID0+IHJlcy5zdGF0dXMoNDA0KS5qc29uKHsgZXJyb3I6ICdVcGxvYWQgbm90IGZvdW5kJyB9KSk7CmFwcC51c2UoJy9zdGF0aWMnLCAocmVxLCByZXMpID0+IHJlcy5zdGF0dXMoNDA0KS5qc29uKHsgZXJyb3I6ICdTdGF0aWMgYXNzZXQgbm90IGZvdW5kJyB9KSk7CgphcHAudXNlKCcvYXBpL2RvZ3MnLCByZXF1aXJlKCcuL3JvdXRlcy9kb2dzJykpOwphcHAudXNlKCcvYXBpL2xpdHRlcnMnLCByZXF1aXJlKCcuL3JvdXRlcy9saXR0ZXJzJykpOwphcHAudXNlKCcvYXBpL2hlYWx0aCcsIHJlcXVpcmUoJy4vcm91dGVzL2hlYWx0aCcpKTsKYXBwLnVzZSgnL2FwaS9wZWRpZ3JlZScsIHJlcXVpcmUoJy4vcm91dGVzL3BlZGlncmVlJykpOwphcHAudXNlKCcvYXBpL2JyZWVkaW5nJywgcmVxdWlyZSgnLi9yb3V0ZXMvYnJlZWRpbmcnKSk7CmFwcC51c2UoJy9hcGkvc2V0dGluZ3MnLCByZXF1aXJlKCcuL3JvdXRlcy9zZXR0aW5ncycpKTsKCmFwcC5nZXQoJy9hcGkvaGVhbHRoJywgKHJlcSwgcmVzKSA9PiB7CiAgcmVzLmpzb24oeyBzdGF0dXM6ICdvaycsIHRpbWVzdGFtcDogbmV3IERhdGUoKS50b0lTT1N0cmluZygpIH0pOwp9KTsKCmlmIChwcm9jZXNzLmVudi5OT0RFX0VOViA9PT0gJ3Byb2R1Y3Rpb24nKSB7CiAgY29uc3QgY2xpZW50QnVpbGRQYXRoID0gcGF0aC5qb2luKF9fZGlybmFtZSwgJy4uL2NsaWVudC9kaXN0Jyk7CiAgYXBwLnVzZShleHByZXNzLnN0YXRpYyhjbGllbnRCdWlsZFBhdGgpKTsKICBhcHAuZ2V0KC9eKD8hXC8oYXBpfHN0YXRpY3x1cGxvYWRzKVwvKS4qJC8sIChyZXEsIHJlcykgPT4gewogICAgcmVzLnNlbmRGaWxlKHBhdGguam9pbihjbGllbnRCdWlsZFBhdGgsICdpbmRleC5odG1sJykpOwogIH0pOwp9CgphcHAudXNlKChlcnIsIHJlcSwgcmVzLCBuZXh0KSA9PiB7CiAgY29uc29sZS5lcnJvcignRXJyb3I6JywgZXJyKTsKICByZXMuc3RhdHVzKGVyci5zdGF0dXMgfHwgNTAwKS5qc29uKHsKICAgIGVycm9yOiBlcnIubWVzc2FnZSB8fCAnSW50ZXJuYWwgc2VydmVyIGVycm9yJywKICAgIC4uLihwcm9jZXNzLmVudi5OT0RFX0VOViA9PT0gJ2RldmVsb3BtZW50JyAmJiB7IHN0YWNrOiBlcnIuc3RhY2sgfSkKICB9KTsKfSk7CgphcHAubGlzdGVuKFBPUlQsICcwLjAuMC4wJywgKCkgPT4gewogIGNvbnNvbGUubG9nKGBcbvCfkJUgQlJFRURSIFNlcnZlciBSdW5uaW5nYCk7CiAgY29uc29sZS5sb2coYD09PT09PT09PT09PT09PT09PT09PT09PT09PT09PWApOwogIGNvbnNvbGUubG9nKGBFbnZpcm9ubWVudDogJHtwcm9jZXNzLmVudi5OT0RFX0VOViB8fCAnZGV2ZWxvcG1lbnQnfWApOwogIGNvbnNvbGUubG9nKGBQb3J0OiAke1BPUlR9YCk7CiAgY29uc29sZS5sb2coYERhdGFiYXNlOiAke0RCX1BBVEh9YCk7CiAgY29uc29sZS5sb2coYFVwbG9hZHM6ICR7VVBMT0FEX1BBVEh9YCk7CiAgY29uc29sZS5sb2coYFN0YXRpYzogJHtTVEFUSUNfUEFUSH1gKTsKICBjb25zb2xlLmxvZyhgQWNjZXNzOiBodHRwOi8vbG9jYWxob3N0OiR7UE9SVH1gKTsKICBjb25zb2xlLmxvZyhgPT09PT09PT09PT09PT09PT09PT09PT09PT09PT09XG5gKTsKfSk7Cgptb2R1bGUuZXhwb3J0cyA9IGFwcDsK
|
||||||
const cors = require('cors');
|
|
||||||
const helmet = require('helmet');
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
const { initDatabase } = require('./db/init');
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
const PORT = process.env.PORT || 3000;
|
|
||||||
const DB_PATH = process.env.DB_PATH || path.join(__dirname, '../data/breedr.db');
|
|
||||||
const UPLOAD_PATH = process.env.UPLOAD_PATH || path.join(__dirname, '../uploads');
|
|
||||||
const STATIC_PATH = process.env.STATIC_PATH || path.join(__dirname, '../static');
|
|
||||||
|
|
||||||
// Ensure directories exist
|
|
||||||
const dataDir = path.dirname(DB_PATH);
|
|
||||||
if (!fs.existsSync(dataDir)) {
|
|
||||||
fs.mkdirSync(dataDir, { recursive: true });
|
|
||||||
}
|
|
||||||
if (!fs.existsSync(UPLOAD_PATH)) {
|
|
||||||
fs.mkdirSync(UPLOAD_PATH, { recursive: true });
|
|
||||||
}
|
|
||||||
if (!fs.existsSync(STATIC_PATH)) {
|
|
||||||
fs.mkdirSync(STATIC_PATH, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize database schema (creates tables if they don't exist)
|
|
||||||
console.log('Initializing database...');
|
|
||||||
initDatabase(DB_PATH);
|
|
||||||
console.log('✓ Database ready!\n');
|
|
||||||
|
|
||||||
// Middleware
|
|
||||||
app.use(helmet({
|
|
||||||
contentSecurityPolicy: false, // Allow inline scripts for React
|
|
||||||
}));
|
|
||||||
app.use(cors());
|
|
||||||
app.use(express.json());
|
|
||||||
app.use(express.urlencoded({ extended: true }));
|
|
||||||
|
|
||||||
// Static asset routes — registered BEFORE React catch-all so they are
|
|
||||||
// resolved directly and never fall through to index.html
|
|
||||||
app.use('/uploads', express.static(UPLOAD_PATH));
|
|
||||||
app.use('/static', express.static(STATIC_PATH));
|
|
||||||
|
|
||||||
// Explicit 404 for missing asset files so the catch-all never intercepts them
|
|
||||||
app.use('/uploads', (req, res) => res.status(404).json({ error: 'Upload not found' }));
|
|
||||||
app.use('/static', (req, res) => res.status(404).json({ error: 'Static asset not found' }));
|
|
||||||
|
|
||||||
// API Routes
|
|
||||||
app.use('/api/dogs', require('./routes/dogs'));
|
|
||||||
app.use('/api/litters', require('./routes/litters'));
|
|
||||||
app.use('/api/health', require('./routes/health'));
|
|
||||||
app.use('/api/pedigree', require('./routes/pedigree'));
|
|
||||||
app.use('/api/breeding', require('./routes/breeding'));
|
|
||||||
|
|
||||||
// Health check endpoint
|
|
||||||
app.get('/api/health', (req, res) => {
|
|
||||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Serve React frontend in production
|
|
||||||
// The catch-all is intentionally placed AFTER all asset/API routes above.
|
|
||||||
// express.static(clientBuildPath) handles real build assets (JS/CSS chunks).
|
|
||||||
// The scoped '*' only fires for HTML5 client-side routes (e.g. /dogs, /litters).
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
|
||||||
const clientBuildPath = path.join(__dirname, '../client/dist');
|
|
||||||
app.use(express.static(clientBuildPath));
|
|
||||||
|
|
||||||
// Only send index.html for non-asset, non-api paths
|
|
||||||
app.get(/^(?!\/(api|static|uploads)\/).*$/, (req, res) => {
|
|
||||||
res.sendFile(path.join(clientBuildPath, 'index.html'));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error handling middleware
|
|
||||||
app.use((err, req, res, next) => {
|
|
||||||
console.error('Error:', err);
|
|
||||||
res.status(err.status || 500).json({
|
|
||||||
error: err.message || 'Internal server error',
|
|
||||||
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start server
|
|
||||||
app.listen(PORT, '0.0.0.0', () => {
|
|
||||||
console.log(`\n🐕 BREEDR Server Running`);
|
|
||||||
console.log(`==============================`);
|
|
||||||
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
|
|
||||||
console.log(`Port: ${PORT}`);
|
|
||||||
console.log(`Database: ${DB_PATH}`);
|
|
||||||
console.log(`Uploads: ${UPLOAD_PATH}`);
|
|
||||||
console.log(`Static: ${STATIC_PATH}`);
|
|
||||||
console.log(`Access: http://localhost:${PORT}`);
|
|
||||||
console.log(`==============================\n`);
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = app;
|
|
||||||
@@ -5,7 +5,6 @@ const multer = require('multer');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|
||||||
// Configure multer for photo uploads
|
|
||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
destination: (req, file, cb) => {
|
destination: (req, file, cb) => {
|
||||||
const uploadPath = process.env.UPLOAD_PATH || path.join(__dirname, '../../uploads');
|
const uploadPath = process.env.UPLOAD_PATH || path.join(__dirname, '../../uploads');
|
||||||
@@ -19,12 +18,10 @@ const storage = multer.diskStorage({
|
|||||||
|
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
storage,
|
storage,
|
||||||
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB limit
|
limits: { fileSize: 10 * 1024 * 1024 },
|
||||||
fileFilter: (req, file, cb) => {
|
fileFilter: (req, file, cb) => {
|
||||||
const allowedTypes = /jpeg|jpg|png|gif|webp/;
|
const allowed = /jpeg|jpg|png|gif|webp/;
|
||||||
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
|
if (allowed.test(path.extname(file.originalname).toLowerCase()) && allowed.test(file.mimetype)) {
|
||||||
const mimetype = allowedTypes.test(file.mimetype);
|
|
||||||
if (extname && mimetype) {
|
|
||||||
cb(null, true);
|
cb(null, true);
|
||||||
} else {
|
} else {
|
||||||
cb(new Error('Only image files are allowed'));
|
cb(new Error('Only image files are allowed'));
|
||||||
@@ -32,29 +29,41 @@ const upload = multer({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper function to convert empty strings to null
|
const emptyToNull = (v) => (v === '' || v === undefined) ? null : v;
|
||||||
const emptyToNull = (value) => {
|
|
||||||
return (value === '' || value === undefined) ? null : value;
|
|
||||||
};
|
|
||||||
|
|
||||||
// GET all dogs
|
// ── Shared SELECT columns ─────────────────────────────────────────────
|
||||||
|
const DOG_COLS = `
|
||||||
|
id, name, registration_number, breed, sex, birth_date,
|
||||||
|
color, microchip, photo_urls, notes, litter_id, is_active,
|
||||||
|
is_champion, created_at, updated_at
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ── GET all dogs ───────────────────────────────────────────────────
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const dogs = db.prepare(`
|
const dogs = db.prepare(`
|
||||||
SELECT id, name, registration_number, breed, sex, birth_date,
|
SELECT ${DOG_COLS}
|
||||||
color, microchip, photo_urls, notes, litter_id, is_active,
|
FROM dogs
|
||||||
created_at, updated_at
|
WHERE is_active = 1
|
||||||
FROM dogs
|
|
||||||
WHERE is_active = 1
|
|
||||||
ORDER BY name
|
ORDER BY name
|
||||||
`).all();
|
`).all();
|
||||||
|
|
||||||
// Parse photo_urls JSON
|
// Also pull sire/dam so list page can compute bloodline status
|
||||||
|
const parentStmt = db.prepare(`
|
||||||
|
SELECT p.parent_type, d.id, d.name, d.is_champion
|
||||||
|
FROM parents p
|
||||||
|
JOIN dogs d ON p.parent_id = d.id
|
||||||
|
WHERE p.dog_id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
dogs.forEach(dog => {
|
dogs.forEach(dog => {
|
||||||
dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
|
dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
|
||||||
|
const parents = parentStmt.all(dog.id);
|
||||||
|
dog.sire = parents.find(p => p.parent_type === 'sire') || null;
|
||||||
|
dog.dam = parents.find(p => p.parent_type === 'dam') || null;
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json(dogs);
|
res.json(dogs);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching dogs:', error);
|
console.error('Error fetching dogs:', error);
|
||||||
@@ -62,42 +71,35 @@ router.get('/', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET single dog by ID with parents and offspring
|
// ── GET single dog (with parents + offspring) ───────────────────────
|
||||||
router.get('/:id', (req, res) => {
|
router.get('/:id', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const dog = db.prepare(`
|
const dog = db.prepare(`SELECT ${DOG_COLS} FROM dogs WHERE id = ?`).get(req.params.id);
|
||||||
SELECT id, name, registration_number, breed, sex, birth_date,
|
|
||||||
color, microchip, photo_urls, notes, litter_id, is_active,
|
if (!dog) return res.status(404).json({ error: 'Dog not found' });
|
||||||
created_at, updated_at
|
|
||||||
FROM dogs
|
|
||||||
WHERE id = ?
|
|
||||||
`).get(req.params.id);
|
|
||||||
|
|
||||||
if (!dog) {
|
|
||||||
return res.status(404).json({ error: 'Dog not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
|
dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
|
||||||
|
|
||||||
// Get parents from parents table
|
// Parents — include is_champion so frontend can render bloodline badge
|
||||||
const parents = db.prepare(`
|
const parents = db.prepare(`
|
||||||
SELECT p.parent_type, d.*
|
SELECT p.parent_type, d.id, d.name, d.is_champion
|
||||||
FROM parents p
|
FROM parents p
|
||||||
JOIN dogs d ON p.parent_id = d.id
|
JOIN dogs d ON p.parent_id = d.id
|
||||||
WHERE p.dog_id = ?
|
WHERE p.dog_id = ?
|
||||||
`).all(req.params.id);
|
`).all(req.params.id);
|
||||||
|
|
||||||
dog.sire = parents.find(p => p.parent_type === 'sire') || null;
|
dog.sire = parents.find(p => p.parent_type === 'sire') || null;
|
||||||
dog.dam = parents.find(p => p.parent_type === 'dam') || null;
|
dog.dam = parents.find(p => p.parent_type === 'dam') || null;
|
||||||
|
|
||||||
// Get offspring
|
// Offspring — include is_champion for badge on offspring cards
|
||||||
dog.offspring = db.prepare(`
|
dog.offspring = db.prepare(`
|
||||||
SELECT d.* FROM dogs d
|
SELECT d.id, d.name, d.sex, d.is_champion
|
||||||
|
FROM dogs d
|
||||||
JOIN parents p ON d.id = p.dog_id
|
JOIN parents p ON d.id = p.dog_id
|
||||||
WHERE p.parent_id = ? AND d.is_active = 1
|
WHERE p.parent_id = ? AND d.is_active = 1
|
||||||
`).all(req.params.id);
|
`).all(req.params.id);
|
||||||
|
|
||||||
res.json(dog);
|
res.json(dog);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching dog:', error);
|
console.error('Error fetching dog:', error);
|
||||||
@@ -105,66 +107,50 @@ router.get('/:id', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST create new dog
|
// ── POST create dog ────────────────────────────────────────────────
|
||||||
router.post('/', (req, res) => {
|
router.post('/', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { name, registration_number, breed, sex, birth_date, color, microchip, notes, sire_id, dam_id, litter_id } = req.body;
|
const { name, registration_number, breed, sex, birth_date, color,
|
||||||
|
microchip, notes, sire_id, dam_id, litter_id, is_champion } = req.body;
|
||||||
console.log('Creating dog with data:', { name, breed, sex, sire_id, dam_id, litter_id });
|
|
||||||
|
console.log('Creating dog:', { name, breed, sex, sire_id, dam_id, litter_id, is_champion });
|
||||||
|
|
||||||
if (!name || !breed || !sex) {
|
if (!name || !breed || !sex) {
|
||||||
return res.status(400).json({ error: 'Name, breed, and sex are required' });
|
return res.status(400).json({ error: 'Name, breed, and sex are required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
|
|
||||||
// Insert dog (dogs table has NO sire/dam columns)
|
|
||||||
const result = db.prepare(`
|
const result = db.prepare(`
|
||||||
INSERT INTO dogs (name, registration_number, breed, sex, birth_date, color, microchip, notes, litter_id, photo_urls)
|
INSERT INTO dogs (name, registration_number, breed, sex, birth_date, color,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
microchip, notes, litter_id, photo_urls, is_champion)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
name,
|
name,
|
||||||
emptyToNull(registration_number),
|
emptyToNull(registration_number),
|
||||||
breed,
|
breed, sex,
|
||||||
sex,
|
emptyToNull(birth_date),
|
||||||
emptyToNull(birth_date),
|
emptyToNull(color),
|
||||||
emptyToNull(color),
|
|
||||||
emptyToNull(microchip),
|
emptyToNull(microchip),
|
||||||
emptyToNull(notes),
|
emptyToNull(notes),
|
||||||
emptyToNull(litter_id),
|
emptyToNull(litter_id),
|
||||||
'[]'
|
'[]',
|
||||||
|
is_champion ? 1 : 0
|
||||||
);
|
);
|
||||||
|
|
||||||
const dogId = result.lastInsertRowid;
|
const dogId = result.lastInsertRowid;
|
||||||
console.log(`✓ Dog inserted with ID: ${dogId}`);
|
console.log(`✓ Dog inserted with ID: ${dogId}`);
|
||||||
|
|
||||||
// Add sire relationship if provided
|
|
||||||
if (sire_id && sire_id !== '' && sire_id !== null) {
|
if (sire_id && sire_id !== '' && sire_id !== null) {
|
||||||
console.log(` Adding sire relationship: dog ${dogId} -> sire ${sire_id}`);
|
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(dogId, sire_id, 'sire');
|
||||||
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').
|
|
||||||
run(dogId, sire_id, 'sire');
|
|
||||||
console.log(` ✓ Sire relationship added`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add dam relationship if provided
|
|
||||||
if (dam_id && dam_id !== '' && dam_id !== null) {
|
if (dam_id && dam_id !== '' && dam_id !== null) {
|
||||||
console.log(` Adding dam relationship: dog ${dogId} -> dam ${dam_id}`);
|
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(dogId, dam_id, 'dam');
|
||||||
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').
|
|
||||||
run(dogId, dam_id, 'dam');
|
|
||||||
console.log(` ✓ Dam relationship added`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch the created dog
|
const dog = db.prepare(`SELECT ${DOG_COLS} FROM dogs WHERE id = ?`).get(dogId);
|
||||||
const dog = db.prepare(`
|
|
||||||
SELECT id, name, registration_number, breed, sex, birth_date,
|
|
||||||
color, microchip, photo_urls, notes, litter_id, is_active,
|
|
||||||
created_at, updated_at
|
|
||||||
FROM dogs
|
|
||||||
WHERE id = ?
|
|
||||||
`).get(dogId);
|
|
||||||
dog.photo_urls = [];
|
dog.photo_urls = [];
|
||||||
|
|
||||||
console.log(`✓ Dog created successfully: ${dog.name} (ID: ${dogId})`);
|
console.log(`✓ Dog created: ${dog.name} (ID: ${dogId})`);
|
||||||
res.status(201).json(dog);
|
res.status(201).json(dog);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating dog:', error);
|
console.error('Error creating dog:', error);
|
||||||
@@ -172,66 +158,47 @@ router.post('/', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// PUT update dog
|
// ── PUT update dog ────────────────────────────────────────────────
|
||||||
router.put('/:id', (req, res) => {
|
router.put('/:id', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { name, registration_number, breed, sex, birth_date, color, microchip, notes, sire_id, dam_id, litter_id } = req.body;
|
const { name, registration_number, breed, sex, birth_date, color,
|
||||||
|
microchip, notes, sire_id, dam_id, litter_id, is_champion } = req.body;
|
||||||
console.log(`Updating dog ${req.params.id} with data:`, { name, breed, sex, sire_id, dam_id, litter_id });
|
|
||||||
|
console.log(`Updating dog ${req.params.id}:`, { name, breed, sex, sire_id, dam_id, is_champion });
|
||||||
|
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
|
|
||||||
// Update dog record (dogs table has NO sire/dam columns)
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE dogs
|
UPDATE dogs
|
||||||
SET name = ?, registration_number = ?, breed = ?, sex = ?,
|
SET name = ?, registration_number = ?, breed = ?, sex = ?,
|
||||||
birth_date = ?, color = ?, microchip = ?, notes = ?, litter_id = ?
|
birth_date = ?, color = ?, microchip = ?, notes = ?,
|
||||||
|
litter_id = ?, is_champion = ?, updated_at = datetime('now')
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).run(
|
`).run(
|
||||||
name,
|
name,
|
||||||
emptyToNull(registration_number),
|
emptyToNull(registration_number),
|
||||||
breed,
|
breed, sex,
|
||||||
sex,
|
emptyToNull(birth_date),
|
||||||
emptyToNull(birth_date),
|
emptyToNull(color),
|
||||||
emptyToNull(color),
|
|
||||||
emptyToNull(microchip),
|
emptyToNull(microchip),
|
||||||
emptyToNull(notes),
|
emptyToNull(notes),
|
||||||
emptyToNull(litter_id),
|
emptyToNull(litter_id),
|
||||||
|
is_champion ? 1 : 0,
|
||||||
req.params.id
|
req.params.id
|
||||||
);
|
);
|
||||||
console.log(` ✓ Dog record updated`);
|
|
||||||
|
// Re-link parents
|
||||||
// Remove existing parent relationships
|
|
||||||
db.prepare('DELETE FROM parents WHERE dog_id = ?').run(req.params.id);
|
db.prepare('DELETE FROM parents WHERE dog_id = ?').run(req.params.id);
|
||||||
console.log(` ✓ Old parent relationships removed`);
|
|
||||||
|
|
||||||
// Add new sire relationship if provided
|
|
||||||
if (sire_id && sire_id !== '' && sire_id !== null) {
|
if (sire_id && sire_id !== '' && sire_id !== null) {
|
||||||
console.log(` Adding sire relationship: dog ${req.params.id} -> sire ${sire_id}`);
|
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(req.params.id, sire_id, 'sire');
|
||||||
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').
|
|
||||||
run(req.params.id, sire_id, 'sire');
|
|
||||||
console.log(` ✓ Sire relationship added`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add new dam relationship if provided
|
|
||||||
if (dam_id && dam_id !== '' && dam_id !== null) {
|
if (dam_id && dam_id !== '' && dam_id !== null) {
|
||||||
console.log(` Adding dam relationship: dog ${req.params.id} -> dam ${dam_id}`);
|
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(req.params.id, dam_id, 'dam');
|
||||||
db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').
|
|
||||||
run(req.params.id, dam_id, 'dam');
|
|
||||||
console.log(` ✓ Dam relationship added`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch updated dog
|
const dog = db.prepare(`SELECT ${DOG_COLS} FROM dogs WHERE id = ?`).get(req.params.id);
|
||||||
const dog = db.prepare(`
|
|
||||||
SELECT id, name, registration_number, breed, sex, birth_date,
|
|
||||||
color, microchip, photo_urls, notes, litter_id, is_active,
|
|
||||||
created_at, updated_at
|
|
||||||
FROM dogs
|
|
||||||
WHERE id = ?
|
|
||||||
`).get(req.params.id);
|
|
||||||
dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
|
dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
|
||||||
|
|
||||||
console.log(`✓ Dog updated successfully: ${dog.name} (ID: ${req.params.id})`);
|
console.log(`✓ Dog updated: ${dog.name} (ID: ${req.params.id})`);
|
||||||
res.json(dog);
|
res.json(dog);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating dog:', error);
|
console.error('Error updating dog:', error);
|
||||||
@@ -239,7 +206,7 @@ router.put('/:id', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE dog (soft delete)
|
// ── DELETE dog (soft) ───────────────────────────────────────────────
|
||||||
router.delete('/:id', (req, res) => {
|
router.delete('/:id', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
@@ -252,25 +219,19 @@ router.delete('/:id', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST upload photo for dog
|
// ── POST upload photo ───────────────────────────────────────────────
|
||||||
router.post('/:id/photos', upload.single('photo'), (req, res) => {
|
router.post('/:id/photos', upload.single('photo'), (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!req.file) {
|
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||||
return res.status(400).json({ error: 'No file uploaded' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const dog = db.prepare('SELECT photo_urls FROM dogs WHERE id = ?').get(req.params.id);
|
const dog = db.prepare('SELECT photo_urls FROM dogs WHERE id = ?').get(req.params.id);
|
||||||
|
if (!dog) return res.status(404).json({ error: 'Dog not found' });
|
||||||
if (!dog) {
|
|
||||||
return res.status(404).json({ error: 'Dog not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const photoUrls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
|
const photoUrls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
|
||||||
photoUrls.push(`/uploads/${req.file.filename}`);
|
photoUrls.push(`/uploads/${req.file.filename}`);
|
||||||
|
|
||||||
db.prepare('UPDATE dogs SET photo_urls = ? WHERE id = ?').run(JSON.stringify(photoUrls), req.params.id);
|
db.prepare('UPDATE dogs SET photo_urls = ? WHERE id = ?').run(JSON.stringify(photoUrls), req.params.id);
|
||||||
|
|
||||||
res.json({ url: `/uploads/${req.file.filename}`, photos: photoUrls });
|
res.json({ url: `/uploads/${req.file.filename}`, photos: photoUrls });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error uploading photo:', error);
|
console.error('Error uploading photo:', error);
|
||||||
@@ -278,31 +239,26 @@ router.post('/:id/photos', upload.single('photo'), (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE photo from dog
|
// ── DELETE photo ─────────────────────────────────────────────────────
|
||||||
router.delete('/:id/photos/:photoIndex', (req, res) => {
|
router.delete('/:id/photos/:photoIndex', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const dog = db.prepare('SELECT photo_urls FROM dogs WHERE id = ?').get(req.params.id);
|
const dog = db.prepare('SELECT photo_urls FROM dogs WHERE id = ?').get(req.params.id);
|
||||||
|
if (!dog) return res.status(404).json({ error: 'Dog not found' });
|
||||||
if (!dog) {
|
|
||||||
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);
|
const photoIndex = parseInt(req.params.photoIndex);
|
||||||
|
|
||||||
if (photoIndex >= 0 && photoIndex < photoUrls.length) {
|
if (photoIndex >= 0 && photoIndex < photoUrls.length) {
|
||||||
const photoPath = path.join(process.env.UPLOAD_PATH || path.join(__dirname, '../../uploads'), path.basename(photoUrls[photoIndex]));
|
const photoPath = path.join(
|
||||||
|
process.env.UPLOAD_PATH || path.join(__dirname, '../../uploads'),
|
||||||
// Delete file from disk
|
path.basename(photoUrls[photoIndex])
|
||||||
if (fs.existsSync(photoPath)) {
|
);
|
||||||
fs.unlinkSync(photoPath);
|
if (fs.existsSync(photoPath)) fs.unlinkSync(photoPath);
|
||||||
}
|
|
||||||
|
|
||||||
photoUrls.splice(photoIndex, 1);
|
photoUrls.splice(photoIndex, 1);
|
||||||
db.prepare('UPDATE dogs SET photo_urls = ? WHERE id = ?').run(JSON.stringify(photoUrls), req.params.id);
|
db.prepare('UPDATE dogs SET photo_urls = ? WHERE id = ?').run(JSON.stringify(photoUrls), req.params.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ photos: photoUrls });
|
res.json({ photos: photoUrls });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting photo:', error);
|
console.error('Error deleting photo:', error);
|
||||||
|
|||||||
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