Initial app

This commit is contained in:
blaisadmin
2026-04-06 23:36:12 -04:00
parent 9405327f74
commit 68ab8b12d2
20 changed files with 5626 additions and 152 deletions
+11
View File
@@ -0,0 +1,11 @@
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY tsconfig*.json ./
COPY vite.config.ts ./
COPY index.html ./
COPY public ./public
COPY src ./src
EXPOSE 3000
CMD ["npm", "run", "dev", "--", "--host"]
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FlockPal</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1721
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "flockpal-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "18.3.1",
"react-dom": "18.3.1"
},
"devDependencies": {
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@vitejs/plugin-react": "4.3.4",
"typescript": "5.6.3",
"vite": "5.4.10"
}
}
+879
View File
@@ -0,0 +1,879 @@
import { useEffect, useMemo, useState } from 'react';
type Bird = {
id: string;
name: string;
tagId: string;
species: string;
dateOfBirth: string | null;
gotchaDay: string | null;
createdAt: string;
latestWeightGrams: number | null;
latestRecordedOn: string | null;
};
type WeightRecord = {
id: string;
birdId: string;
weightGrams: number;
recordedOn: string;
notes: string | null;
};
type VetVisit = {
id: string;
birdId: string;
visitedOn: string;
clinicName: string;
reason: string;
notes: string | null;
};
type AppPage = 'overview' | 'flock' | 'settings';
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api';
const formatDate = (value: string | null) => {
if (!value) {
return 'Not set';
}
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
}).format(new Date(`${value}T00:00:00`));
};
const formatShortDate = (value: string | null) => {
if (!value) {
return 'No data yet';
}
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
}).format(new Date(`${value}T00:00:00`));
};
const formatWeight = (value: number | null) => (value ? `${value.toFixed(1)} g` : 'Pending');
const chartPath = (points: WeightRecord[], width = 520, height = 180) => {
if (!points.length) {
return '';
}
const weights = points.map((point) => point.weightGrams);
const min = Math.min(...weights);
const max = Math.max(...weights);
const spread = Math.max(max - min, 1);
return points
.map((point, index) => {
const x = (index / Math.max(points.length - 1, 1)) * width;
const y = height - ((point.weightGrams - min) / spread) * (height - 24) - 12;
return `${index === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`;
})
.join(' ');
};
const chartDots = (points: WeightRecord[], width = 520, height = 180) => {
if (!points.length) {
return [];
}
const weights = points.map((point) => point.weightGrams);
const min = Math.min(...weights);
const max = Math.max(...weights);
const spread = Math.max(max - min, 1);
return points.map((point, index) => ({
id: point.id,
x: (index / Math.max(points.length - 1, 1)) * width,
y: height - ((point.weightGrams - min) / spread) * (height - 24) - 12,
label: `${point.weightGrams.toFixed(1)} g on ${formatShortDate(point.recordedOn)}`,
}));
};
const birdLineStyles = [
{ stroke: '#cb3a35' },
{ stroke: '#238a5a' },
{ stroke: '#2769b3' },
{ stroke: '#f0b63f' },
{ stroke: '#2f8f98' },
];
function App() {
const [activePage, setActivePage] = useState<AppPage>('overview');
const [birds, setBirds] = useState<Bird[]>([]);
const [selectedBirdId, setSelectedBirdId] = useState<string>('');
const [weights, setWeights] = useState<WeightRecord[]>([]);
const [vetVisits, setVetVisits] = useState<VetVisit[]>([]);
const [allBirdWeights, setAllBirdWeights] = useState<Record<string, WeightRecord[]>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [birdForm, setBirdForm] = useState({
name: '',
tagId: '',
species: '',
dateOfBirth: '',
gotchaDay: '',
});
const [weightForm, setWeightForm] = useState({
weightGrams: '',
recordedOn: new Date().toISOString().slice(0, 10),
notes: '',
});
const [vetVisitForm, setVetVisitForm] = useState({
visitedOn: new Date().toISOString().slice(0, 10),
clinicName: '',
reason: '',
notes: '',
});
const [mergeForm, setMergeForm] = useState({
fromOwner: '',
toOwner: '',
birdName: '',
tagId: '',
notes: '',
});
const [deletingBird, setDeletingBird] = useState(false);
const selectedBird = useMemo(
() => birds.find((bird) => bird.id === selectedBirdId) ?? birds[0] ?? null,
[birds, selectedBirdId],
);
const totalWeightEntries = useMemo(
() => Object.values(allBirdWeights).reduce((total, entries) => total + entries.length, 0),
[allBirdWeights],
);
const birdsWithRecentWeights = useMemo(
() => birds.filter((bird) => (allBirdWeights[bird.id] ?? []).length > 0),
[allBirdWeights, birds],
);
const trendCopy = useMemo(() => {
if (weights.length < 2) {
return 'Needs a few more entries before trend detection.';
}
const first = weights[0].weightGrams;
const last = weights[weights.length - 1].weightGrams;
const delta = last - first;
if (Math.abs(delta) < 1) {
return 'Weight has been steady over the last visible entries.';
}
return delta > 0
? `Weight is up ${delta.toFixed(1)} g over the current window.`
: `Weight is down ${Math.abs(delta).toFixed(1)} g over the current window.`;
}, [weights]);
useEffect(() => {
const loadBirds = async () => {
try {
setLoading(true);
const response = await fetch(`${apiBaseUrl}/birds`);
const data = await response.json();
const nextBirds = data.birds ?? [];
setBirds(nextBirds);
setSelectedBirdId((current) => current || nextBirds[0]?.id || '');
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : 'Unable to load flock members.');
} finally {
setLoading(false);
}
};
void loadBirds();
}, []);
useEffect(() => {
if (!selectedBird?.id) {
setWeights([]);
setVetVisits([]);
return;
}
const loadBirdDetail = async () => {
try {
const [weightsResponse, visitsResponse] = await Promise.all([
fetch(`${apiBaseUrl}/birds/${selectedBird.id}/weights?days=90`),
fetch(`${apiBaseUrl}/birds/${selectedBird.id}/vet-visits`),
]);
const weightsData = await weightsResponse.json();
const visitsData = await visitsResponse.json();
setWeights(weightsData.weights ?? []);
setVetVisits(visitsData.vetVisits ?? []);
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : 'Unable to load flock member details.');
}
};
void loadBirdDetail();
}, [selectedBird?.id]);
useEffect(() => {
if (!birds.length) {
setAllBirdWeights({});
return;
}
const loadAllBirdWeights = async () => {
try {
const responses = await Promise.all(
birds.map(async (bird) => {
const response = await fetch(`${apiBaseUrl}/birds/${bird.id}/weights?days=30`);
const data = await response.json();
return [bird.id, (data.weights ?? []) as WeightRecord[]] as const;
}),
);
setAllBirdWeights(Object.fromEntries(responses));
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : 'Unable to load overview weights.');
}
};
void loadAllBirdWeights();
}, [birds]);
const handleBirdSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError('');
try {
const response = await fetch(`${apiBaseUrl}/birds`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(birdForm),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error ?? 'Unable to create flock member.');
}
const data = await response.json();
setBirds((current) => [...current, data.bird].sort((left, right) => left.name.localeCompare(right.name)));
setSelectedBirdId(data.bird.id);
setBirdForm({
name: '',
tagId: '',
species: '',
dateOfBirth: '',
gotchaDay: '',
});
setActivePage('flock');
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : 'Unable to create flock member.');
}
};
const handleWeightSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!selectedBird) {
return;
}
setError('');
try {
const response = await fetch(`${apiBaseUrl}/birds/${selectedBird.id}/weights`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
weightGrams: Number(weightForm.weightGrams),
recordedOn: weightForm.recordedOn,
notes: weightForm.notes,
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error ?? 'Unable to save weight.');
}
const data = await response.json();
const nextWeights = [...weights, data.weight].sort((left, right) => left.recordedOn.localeCompare(right.recordedOn));
setWeights(nextWeights);
setAllBirdWeights((current) => ({
...current,
[selectedBird.id]: nextWeights.filter((entry) => {
const limitDate = new Date();
limitDate.setDate(limitDate.getDate() - 29);
return new Date(`${entry.recordedOn}T00:00:00`) >= new Date(limitDate.toDateString());
}),
}));
setBirds((current) =>
current.map((bird) =>
bird.id === selectedBird.id
? {
...bird,
latestWeightGrams: data.weight.weightGrams,
latestRecordedOn: data.weight.recordedOn,
}
: bird,
),
);
setWeightForm({ weightGrams: '', recordedOn: new Date().toISOString().slice(0, 10), notes: '' });
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : 'Unable to save weight.');
}
};
const handleVetVisitSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!selectedBird) {
return;
}
setError('');
try {
const response = await fetch(`${apiBaseUrl}/birds/${selectedBird.id}/vet-visits`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(vetVisitForm),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error ?? 'Unable to save vet visit.');
}
const data = await response.json();
setVetVisits((current) =>
[data.vetVisit, ...current].sort((left, right) => right.visitedOn.localeCompare(left.visitedOn)),
);
setVetVisitForm({
visitedOn: new Date().toISOString().slice(0, 10),
clinicName: '',
reason: '',
notes: '',
});
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : 'Unable to save vet visit.');
}
};
const handleRemoveBird = async () => {
if (!selectedBird || deletingBird) {
return;
}
const confirmed = window.confirm(
`Remove ${selectedBird.name} from the flock?\n\nThis will also remove weight records and vet visits for this flock member.`,
);
if (!confirmed) {
return;
}
setDeletingBird(true);
setError('');
try {
const response = await fetch(`${apiBaseUrl}/birds/${selectedBird.id}`, {
method: 'DELETE',
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error ?? 'Unable to remove flock member.');
}
const nextBirds = birds.filter((bird) => bird.id !== selectedBird.id);
setBirds(nextBirds);
setAllBirdWeights((current) => {
const next = { ...current };
delete next[selectedBird.id];
return next;
});
setSelectedBirdId(nextBirds[0]?.id ?? '');
setWeights([]);
setVetVisits([]);
} catch (removeError) {
setError(removeError instanceof Error ? removeError.message : 'Unable to remove flock member.');
} finally {
setDeletingBird(false);
}
};
const handleMergeSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError('');
window.alert(
`Transfer prep saved for ${mergeForm.birdName || 'bird'}.\n\nThis is currently a planning workflow only. Later this page can turn into a real account-to-account transfer flow using verified bird identity and ownership checks.`,
);
setMergeForm({
fromOwner: '',
toOwner: '',
birdName: '',
tagId: '',
notes: '',
});
};
if (loading) {
return (
<main className="app-shell">
<section className="hero-card">
<p>Loading flock data...</p>
</section>
</main>
);
}
return (
<main className="app-shell">
<section className="hero-card">
<div>
<p className="eyebrow">Bird health tracker</p>
<h1>FlockPal dashboard</h1>
<div className="page-tabs" role="tablist" aria-label="Main navigation">
<button className={`page-tab ${activePage === 'overview' ? 'active' : ''}`} onClick={() => setActivePage('overview')} type="button">
Overview
</button>
<button className={`page-tab ${activePage === 'flock' ? 'active' : ''}`} onClick={() => setActivePage('flock')} type="button">
Flock
</button>
<button className={`page-tab ${activePage === 'settings' ? 'active' : ''}`} onClick={() => setActivePage('settings')} type="button">
Settings
</button>
</div>
</div>
<div className="hero-stats">
<article>
<strong>{birds.length}</strong>
<span>Flock members</span>
</article>
<article>
<strong>{totalWeightEntries}</strong>
<span>Weight records</span>
</article>
<article>
<strong>{selectedBird ? formatWeight(selectedBird.latestWeightGrams) : 'Pending'}</strong>
<span>Selected member</span>
</article>
</div>
</section>
{error ? <p className="error-banner">{error}</p> : null}
{activePage === 'overview' ? (
<section className="stack-grid">
<section className="panel">
<div className="panel-header">
<div>
<p className="eyebrow">Overview</p>
<h2>30-day flock weight snapshot</h2>
</div>
<p className="muted">{birdsWithRecentWeights.length} birds with recent entries</p>
</div>
<div className="chart-card overview-chart-card">
<svg viewBox="0 0 520 220" className="weight-chart" role="img" aria-label="All birds weight overview">
{birds.map((bird, index) => {
const birdWeights = allBirdWeights[bird.id] ?? [];
const style = birdLineStyles[index % birdLineStyles.length];
const dots = chartDots(birdWeights, 520, 220);
if (!birdWeights.length) {
return null;
}
return (
<g key={bird.id}>
<path d={chartPath(birdWeights, 520, 220)} fill="none" stroke={style.stroke} strokeWidth="3.5" strokeLinecap="round" />
{dots.map((dot) => (
<circle key={dot.id} cx={dot.x} cy={dot.y} r="4.5" fill={style.stroke}>
<title>{`${bird.name}: ${dot.label}`}</title>
</circle>
))}
</g>
);
})}
</svg>
</div>
<div className="legend-grid">
{birds.map((bird, index) => {
const style = birdLineStyles[index % birdLineStyles.length];
const birdWeights = allBirdWeights[bird.id] ?? [];
return (
<article key={bird.id} className="legend-card">
<span className="legend-swatch" style={{ background: style.stroke }} />
<div>
<strong>{bird.name}</strong>
<small>
{bird.species} {birdWeights.length ? `${birdWeights.length} entries` : 'No entries yet'}
</small>
</div>
</article>
);
})}
</div>
</section>
<section className="forms-grid">
<article className="panel form-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Highlights</p>
<h2>Flock health pulse</h2>
</div>
</div>
<div className="summary-grid">
<article className="summary-card">
<strong>{birdsWithRecentWeights.length}</strong>
<span>Flock members with recent measurements</span>
</article>
<article className="summary-card">
<strong>{birds.filter((bird) => bird.latestWeightGrams === null).length}</strong>
<span>Members still needing a first weight</span>
</article>
<article className="summary-card">
<strong>{selectedBird ? trendCopy : 'Pick a bird'}</strong>
<span>Selected flock member trend</span>
</article>
</div>
</article>
<article className="panel form-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Recent activity</p>
<h2>Latest check-ins</h2>
</div>
</div>
<div className="recent-list">
{birds
.filter((bird) => bird.latestRecordedOn)
.sort((left, right) => (right.latestRecordedOn ?? '').localeCompare(left.latestRecordedOn ?? ''))
.slice(0, 5)
.map((bird) => (
<article key={bird.id}>
<strong>{bird.name}</strong>
<span>{formatWeight(bird.latestWeightGrams)}</span>
<small>{formatShortDate(bird.latestRecordedOn)}</small>
</article>
))}
</div>
</article>
</section>
</section>
) : null}
{activePage === 'flock' ? (
<>
<section className="dashboard-grid">
<aside className="panel bird-list-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Flock</p>
<h2>Flock members</h2>
</div>
</div>
<div className="bird-list">
{birds.map((bird) => (
<button
key={bird.id}
className={`bird-card ${bird.id === selectedBird?.id ? 'active' : ''}`}
onClick={() => setSelectedBirdId(bird.id)}
type="button"
>
<span>{bird.name}</span>
<small>
{bird.species} {bird.tagId}
</small>
<strong>{formatWeight(bird.latestWeightGrams)}</strong>
</button>
))}
</div>
</aside>
<section className="panel flock-member-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Flock member</p>
<h2>{selectedBird ? selectedBird.name : 'Choose a flock member'}</h2>
</div>
{selectedBird ? (
<button className="danger-button" onClick={handleRemoveBird} type="button" disabled={deletingBird}>
{deletingBird ? 'Removing...' : 'Remove from flock'}
</button>
) : null}
</div>
{selectedBird ? (
<>
<div className="detail-grid">
<article className="detail-card">
<span>Name</span>
<strong>{selectedBird.name}</strong>
</article>
<article className="detail-card">
<span>Band ID</span>
<strong>{selectedBird.tagId}</strong>
</article>
<article className="detail-card">
<span>DOB</span>
<strong>{formatDate(selectedBird.dateOfBirth)}</strong>
</article>
<article className="detail-card">
<span>Gotcha day</span>
<strong>{formatDate(selectedBird.gotchaDay)}</strong>
</article>
<article className="detail-card">
<span>Species</span>
<strong>{selectedBird.species}</strong>
</article>
<article className="detail-card">
<span>Latest weight</span>
<strong>{formatWeight(selectedBird.latestWeightGrams)}</strong>
</article>
</div>
<div className="flock-member-sections">
<section className="panel inset-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Weight</p>
<h2>Trend and log</h2>
</div>
<p className="muted">Latest reading: {formatShortDate(selectedBird.latestRecordedOn)}</p>
</div>
<div className="chart-card">
<svg viewBox="0 0 520 180" className="weight-chart" role="img" aria-label="Selected flock member weight trend chart">
<defs>
<linearGradient id="lineGlow" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#cb3a35" />
<stop offset="38%" stopColor="#f0b63f" />
<stop offset="68%" stopColor="#238a5a" />
<stop offset="100%" stopColor="#2769b3" />
</linearGradient>
</defs>
<path d={chartPath(weights)} fill="none" stroke="url(#lineGlow)" strokeWidth="4" strokeLinecap="round" />
</svg>
<div className="chart-footer">
<p>{trendCopy}</p>
<span>{weights.length ? `${weights.length} data point${weights.length === 1 ? '' : 's'}` : 'No data yet'}</span>
</div>
</div>
<form className="form-panel inline-form" onSubmit={handleWeightSubmit}>
<label>
Weight in grams
<input
type="number"
min="1"
step="0.1"
value={weightForm.weightGrams}
onChange={(event) => setWeightForm({ ...weightForm, weightGrams: event.target.value })}
required
/>
</label>
<label>
Recorded on
<input
type="date"
value={weightForm.recordedOn}
onChange={(event) => setWeightForm({ ...weightForm, recordedOn: event.target.value })}
required
/>
</label>
<label className="wide-field">
Notes
<textarea
rows={3}
value={weightForm.notes}
onChange={(event) => setWeightForm({ ...weightForm, notes: event.target.value })}
placeholder="Optional notes about appetite, molt, meds, or behavior"
/>
</label>
<button className="primary-button" type="submit">
Save weight
</button>
</form>
</section>
<section className="panel inset-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Vet visits</p>
<h2>Care history and notes</h2>
</div>
</div>
<form className="form-panel inline-form" onSubmit={handleVetVisitSubmit}>
<label>
Visit date
<input
type="date"
value={vetVisitForm.visitedOn}
onChange={(event) => setVetVisitForm({ ...vetVisitForm, visitedOn: event.target.value })}
required
/>
</label>
<label>
Clinic
<input
value={vetVisitForm.clinicName}
onChange={(event) => setVetVisitForm({ ...vetVisitForm, clinicName: event.target.value })}
required
/>
</label>
<label>
Reason
<input
value={vetVisitForm.reason}
onChange={(event) => setVetVisitForm({ ...vetVisitForm, reason: event.target.value })}
required
/>
</label>
<label className="wide-field">
Notes
<textarea
rows={3}
value={vetVisitForm.notes}
onChange={(event) => setVetVisitForm({ ...vetVisitForm, notes: event.target.value })}
placeholder="Exam notes, medications, follow-ups, or restrictions"
/>
</label>
<button className="primary-button" type="submit">
Save vet visit
</button>
</form>
<div className="recent-list">
{vetVisits.length ? (
vetVisits.map((visit) => (
<article key={visit.id} className="vet-visit-card">
<strong>{visit.reason}</strong>
<span>
{formatDate(visit.visitedOn)} {visit.clinicName}
</span>
<small>{visit.notes || 'No notes recorded.'}</small>
</article>
))
) : (
<article className="vet-visit-card empty-card">
<strong>No vet visits yet</strong>
<small>Add the first visit above to start this care history.</small>
</article>
)}
</div>
</section>
</div>
</>
) : (
<p className="muted">Add a flock member in Settings to start tracking individual health records.</p>
)}
</section>
</section>
</>
) : null}
{activePage === 'settings' ? (
<section className="forms-grid settings-grid">
<article className="panel form-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Flock setup</p>
<h2>Add a flock member</h2>
</div>
</div>
<form className="form-panel" onSubmit={handleBirdSubmit}>
<label>
Bird name
<input value={birdForm.name} onChange={(event) => setBirdForm({ ...birdForm, name: event.target.value })} required />
</label>
<label>
Band ID
<input value={birdForm.tagId} onChange={(event) => setBirdForm({ ...birdForm, tagId: event.target.value })} required />
</label>
<label>
Species
<input value={birdForm.species} onChange={(event) => setBirdForm({ ...birdForm, species: event.target.value })} required />
</label>
<label>
DOB
<input
type="date"
value={birdForm.dateOfBirth}
onChange={(event) => setBirdForm({ ...birdForm, dateOfBirth: event.target.value })}
/>
</label>
<label>
Gotcha day
<input
type="date"
value={birdForm.gotchaDay}
onChange={(event) => setBirdForm({ ...birdForm, gotchaDay: event.target.value })}
/>
</label>
<button className="primary-button" type="submit">
Save flock member
</button>
</form>
</article>
<article className="panel form-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Settings</p>
<h2>Bird transfer prep</h2>
</div>
</div>
<p className="muted">
This is the first step toward rescue handoffs and owner-to-owner transfers. For now it captures the matching details we would later
use to safely move a bird record between accounts.
</p>
<form className="form-panel" onSubmit={handleMergeSubmit}>
<label>
Current owner account
<input value={mergeForm.fromOwner} onChange={(event) => setMergeForm({ ...mergeForm, fromOwner: event.target.value })} required />
</label>
<label>
Destination owner account
<input value={mergeForm.toOwner} onChange={(event) => setMergeForm({ ...mergeForm, toOwner: event.target.value })} required />
</label>
<label>
Bird name
<input value={mergeForm.birdName} onChange={(event) => setMergeForm({ ...mergeForm, birdName: event.target.value })} required />
</label>
<label>
Band / Tag info
<input value={mergeForm.tagId} onChange={(event) => setMergeForm({ ...mergeForm, tagId: event.target.value })} required />
</label>
<label>
Transfer notes
<textarea
rows={4}
value={mergeForm.notes}
onChange={(event) => setMergeForm({ ...mergeForm, notes: event.target.value })}
placeholder="Optional context for rescue release, adoption, or household transfer"
/>
</label>
<button className="primary-button" type="submit">
Save transfer draft
</button>
</form>
</article>
</section>
) : null}
</main>
);
}
export default App;
+490
View File
@@ -0,0 +1,490 @@
:root {
--ink: #1f2a2a;
--muted: #5d5f59;
--panel-border: rgba(31, 110, 78, 0.16);
--panel-bg: rgba(255, 248, 241, 0.82);
--card-bg: linear-gradient(180deg, rgba(255, 240, 231, 0.94), rgba(239, 248, 244, 0.88));
--accent-red: #cb3a35;
--accent-green: #238a5a;
--accent-blue: #2769b3;
--accent-gold: #f0b63f;
--accent-teal: #2f8f98;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(203, 58, 53, 0.24), transparent 24%),
radial-gradient(circle at 88% 22%, rgba(39, 105, 179, 0.22), transparent 20%),
radial-gradient(circle at bottom right, rgba(35, 138, 90, 0.24), transparent 28%),
linear-gradient(180deg, #fff3e8 0%, #f6ead7 55%, #eef7f2 100%);
font-family: "Avenir Next", "Segoe UI", sans-serif;
line-height: 1.5;
font-weight: 400;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
min-height: 100%;
}
body {
min-height: 100vh;
color: var(--ink);
}
button,
input,
textarea {
font: inherit;
}
button {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
}
input,
textarea {
width: 100%;
margin-top: 0.5rem;
border: 1px solid rgba(39, 105, 179, 0.16);
border-radius: 16px;
padding: 0.9rem 1rem;
background: rgba(255, 254, 250, 0.92);
color: var(--ink);
}
input:focus,
textarea:focus {
outline: none;
border-color: rgba(39, 105, 179, 0.62);
box-shadow: 0 0 0 4px rgba(39, 105, 179, 0.14);
}
textarea {
resize: vertical;
}
.app-shell {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
display: grid;
gap: 1.5rem;
}
.stack-grid {
display: grid;
gap: 1.5rem;
}
.hero-card,
.panel {
background: var(--panel-bg);
border: 1px solid var(--panel-border);
box-shadow: 0 22px 44px rgba(89, 48, 42, 0.13);
backdrop-filter: blur(14px);
}
.hero-card {
border-radius: 32px;
padding: 2rem;
display: grid;
grid-template-columns: 1.3fr 0.7fr;
gap: 1.5rem;
background:
linear-gradient(135deg, rgba(203, 58, 53, 0.12), transparent 34%),
linear-gradient(225deg, rgba(39, 105, 179, 0.1), transparent 36%),
linear-gradient(180deg, rgba(255, 248, 241, 0.92), rgba(245, 251, 248, 0.86));
position: relative;
overflow: hidden;
}
.hero-card::after {
content: "";
position: absolute;
inset: auto -8% -42% auto;
width: 280px;
height: 280px;
border-radius: 50%;
background:
radial-gradient(circle at 35% 35%, rgba(240, 182, 63, 0.62), transparent 26%),
radial-gradient(circle at 58% 44%, rgba(35, 138, 90, 0.52), transparent 32%),
radial-gradient(circle at 72% 62%, rgba(39, 105, 179, 0.5), transparent 30%),
radial-gradient(circle at 42% 74%, rgba(203, 58, 53, 0.52), transparent 32%);
pointer-events: none;
opacity: 0.75;
}
.hero-card h1,
.panel h2 {
margin: 0;
}
.hero-card h1 {
font-size: clamp(2.2rem, 5vw, 4rem);
line-height: 1;
max-width: 12ch;
}
.page-tabs {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
margin-top: 1.25rem;
position: relative;
z-index: 1;
}
.page-tab {
border: 1px solid rgba(39, 105, 179, 0.14);
border-radius: 999px;
padding: 0.7rem 1.1rem;
background: rgba(255, 255, 255, 0.54);
color: var(--ink);
transition: transform 0.2s ease, background 0.2s ease, border-color 0.2s ease;
}
.page-tab.active {
background: linear-gradient(135deg, rgba(203, 58, 53, 0.92), rgba(39, 105, 179, 0.92));
color: #fffdf9;
border-color: transparent;
}
.page-tab:hover {
transform: translateY(-1px);
border-color: rgba(35, 138, 90, 0.34);
}
.eyebrow {
margin: 0 0 0.5rem;
text-transform: uppercase;
letter-spacing: 0.18em;
font-size: 0.72rem;
color: var(--accent-red);
}
.lede,
.muted,
.chart-footer span,
.recent-list small,
.bird-card small {
color: var(--muted);
}
.hero-stats,
.dashboard-grid,
.forms-grid {
display: grid;
gap: 1.5rem;
}
.hero-stats {
grid-template-columns: repeat(3, 1fr);
align-self: end;
}
.hero-stats article,
.chart-card,
.recent-list article,
.bird-card {
border-radius: 24px;
background: var(--card-bg);
border: 1px solid rgba(39, 105, 179, 0.12);
}
.hero-stats article {
padding: 1rem;
position: relative;
overflow: hidden;
}
.hero-stats strong {
display: block;
font-size: 1.7rem;
color: var(--accent-green);
}
.hero-stats article::before,
.bird-card::before,
.chart-card::before {
content: "";
display: block;
height: 5px;
border-radius: 999px;
background: linear-gradient(90deg, var(--accent-red), var(--accent-gold), var(--accent-green), var(--accent-blue));
}
.hero-stats article::before {
margin-bottom: 0.9rem;
}
.hero-stats article:nth-child(2) strong {
color: var(--accent-blue);
}
.hero-stats article:nth-child(3) strong {
color: var(--accent-red);
}
.dashboard-grid {
grid-template-columns: 320px 1fr;
}
.forms-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.settings-grid {
align-items: start;
}
.flock-member-panel,
.flock-member-sections {
display: grid;
gap: 1.5rem;
}
.panel {
border-radius: 28px;
padding: 1.5rem;
}
.panel-header {
display: flex;
align-items: start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.bird-list {
display: grid;
gap: 0.9rem;
}
.bird-card {
width: 100%;
text-align: left;
padding: 1rem;
border: 1px solid rgba(95, 121, 77, 0.12);
display: grid;
gap: 0.35rem;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
position: relative;
overflow: hidden;
}
.bird-card:hover,
.bird-card.active {
transform: translateY(-2px);
box-shadow: 0 16px 24px rgba(39, 105, 179, 0.15);
border-color: rgba(35, 138, 90, 0.42);
}
.bird-card::before {
position: absolute;
inset: 0 auto 0 0;
width: 6px;
height: auto;
margin: 0;
border-radius: 0 999px 999px 0;
}
.chart-card {
padding: 1rem;
background:
linear-gradient(180deg, rgba(255, 248, 236, 0.92), rgba(240, 249, 245, 0.88)),
var(--card-bg);
position: relative;
overflow: hidden;
}
.chart-card::before {
margin-bottom: 1rem;
}
.chart-card::after {
content: "";
position: absolute;
top: 1rem;
right: 1rem;
width: 110px;
height: 110px;
border-radius: 50%;
background:
radial-gradient(circle, rgba(39, 105, 179, 0.14), transparent 58%);
pointer-events: none;
}
.overview-chart-card {
min-height: 260px;
}
.weight-chart {
width: 100%;
height: auto;
min-height: 180px;
}
.chart-footer,
.recent-list,
.detail-grid,
.summary-grid,
.legend-grid,
.inline-form {
display: grid;
gap: 0.85rem;
}
.chart-footer {
grid-template-columns: 1fr auto;
align-items: center;
}
.recent-list article {
padding: 0.9rem 1rem;
display: grid;
gap: 0.25rem;
}
.legend-grid,
.detail-grid,
.summary-grid {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.inline-form {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.legend-card,
.detail-card,
.summary-card,
.vet-visit-card {
padding: 1rem;
border-radius: 20px;
background: linear-gradient(180deg, rgba(255, 252, 247, 0.9), rgba(240, 248, 244, 0.82));
border: 1px solid rgba(39, 105, 179, 0.1);
display: grid;
gap: 0.35rem;
}
.inset-panel {
padding: 1.25rem;
background: linear-gradient(180deg, rgba(255, 252, 247, 0.74), rgba(241, 248, 244, 0.72));
}
.wide-field {
grid-column: 1 / -1;
}
.legend-card {
grid-template-columns: auto 1fr;
align-items: center;
}
.legend-swatch {
width: 14px;
height: 14px;
border-radius: 50%;
display: inline-block;
box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.65);
}
.detail-card span,
.summary-card span {
color: var(--muted);
font-size: 0.95rem;
}
.detail-card strong,
.summary-card strong,
.legend-card strong {
font-size: 1.05rem;
}
.vet-visit-card span,
.vet-visit-card small {
color: var(--muted);
}
.empty-card {
opacity: 0.82;
}
.form-panel {
display: grid;
gap: 1rem;
}
label {
display: block;
font-weight: 600;
}
.primary-button {
border: 0;
border-radius: 18px;
padding: 0.95rem 1.2rem;
color: #fffdf9;
background: linear-gradient(135deg, var(--accent-red), var(--accent-blue));
box-shadow: 0 14px 28px rgba(39, 105, 179, 0.2);
}
.primary-button:hover {
background: linear-gradient(135deg, #b7312d, #1f5e9f);
}
.danger-button {
border: 1px solid rgba(171, 44, 44, 0.18);
border-radius: 18px;
padding: 0.95rem 1.2rem;
color: #fffaf8;
background: linear-gradient(135deg, #bc3733, #8e2523);
box-shadow: 0 12px 24px rgba(142, 37, 35, 0.18);
}
.danger-button:hover {
background: linear-gradient(135deg, #aa2f2c, #7d201e);
}
.primary-button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.error-banner {
margin: 0;
padding: 1rem 1.2rem;
border-radius: 18px;
background: rgba(203, 58, 53, 0.1);
border: 1px solid rgba(203, 58, 53, 0.2);
color: #922728;
}
@media (max-width: 980px) {
.hero-card,
.dashboard-grid,
.forms-grid,
.hero-stats,
.chart-footer,
.inline-form {
grid-template-columns: 1fr;
}
.app-shell {
padding: 1rem;
}
.page-tabs {
margin-top: 1rem;
}
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+9
View File
@@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
},
});