visual improvements

This commit is contained in:
Corey Blais
2026-04-07 18:23:28 -04:00
parent 68ab8b12d2
commit 2aff57ee7f
5 changed files with 961 additions and 370 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "node --import tsx src/app.ts", "dev": "tsx watch src/app.ts",
"build": "tsc", "build": "tsc",
"start": "node dist/app.js" "start": "node dist/app.js"
}, },
+100 -23
View File
@@ -39,6 +39,11 @@ const allowedOrigins = Array.from(
); );
const dateStringSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); const dateStringSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
const chartColorSchema = z.string().regex(/^#[0-9a-fA-F]{6}$/);
const photoDataUrlSchema = z
.string()
.regex(/^data:image\/(?:png|jpeg|jpg|webp|gif);base64,[A-Za-z0-9+/=]+$/)
.max(1_500_000);
const birdSchema = z.object({ const birdSchema = z.object({
name: z.string().trim().min(1).max(120), name: z.string().trim().min(1).max(120),
@@ -46,6 +51,8 @@ const birdSchema = z.object({
species: z.string().trim().min(1).max(120), species: z.string().trim().min(1).max(120),
dateOfBirth: dateStringSchema.optional().or(z.literal('')), dateOfBirth: dateStringSchema.optional().or(z.literal('')),
gotchaDay: dateStringSchema.optional().or(z.literal('')), gotchaDay: dateStringSchema.optional().or(z.literal('')),
chartColor: chartColorSchema.optional(),
photoDataUrl: photoDataUrlSchema.optional().or(z.literal('')),
}); });
const weightSchema = z.object({ const weightSchema = z.object({
@@ -68,6 +75,8 @@ type BirdRow = {
species: string; species: string;
date_of_birth: string | null; date_of_birth: string | null;
gotcha_day: string | null; gotcha_day: string | null;
chart_color: string;
photo_data_url: string | null;
created_at: string; created_at: string;
latest_weight_grams: string | null; latest_weight_grams: string | null;
latest_recorded_on: string | null; latest_recorded_on: string | null;
@@ -111,7 +120,7 @@ app.use(
legacyHeaders: false, legacyHeaders: false,
}), }),
); );
app.use(express.json({ limit: '300kb' })); app.use(express.json({ limit: '2mb' }));
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev')); app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
const emptyToNull = (value?: string) => { const emptyToNull = (value?: string) => {
@@ -126,11 +135,27 @@ const normalizeBird = (row: BirdRow) => ({
species: row.species, species: row.species,
dateOfBirth: row.date_of_birth, dateOfBirth: row.date_of_birth,
gotchaDay: row.gotcha_day, gotchaDay: row.gotcha_day,
chartColor: row.chart_color,
photoDataUrl: row.photo_data_url,
createdAt: row.created_at, createdAt: row.created_at,
latestWeightGrams: row.latest_weight_grams ? Number(row.latest_weight_grams) : null, latestWeightGrams: row.latest_weight_grams ? Number(row.latest_weight_grams) : null,
latestRecordedOn: row.latest_recorded_on, latestRecordedOn: row.latest_recorded_on,
}); });
const birdSelectFields = `
birds.id,
birds.name,
birds.tag_id,
birds.species,
birds.date_of_birth::text,
birds.gotcha_day::text,
birds.chart_color,
birds.photo_data_url,
birds.created_at,
latest.weight_grams AS latest_weight_grams,
latest.recorded_on::text AS latest_recorded_on
`;
const normalizeWeight = (row: WeightRow) => ({ const normalizeWeight = (row: WeightRow) => ({
id: row.id, id: row.id,
birdId: row.bird_id, birdId: row.bird_id,
@@ -159,12 +184,16 @@ const ensureSchema = async () => {
species VARCHAR(120) NOT NULL, species VARCHAR(120) NOT NULL,
date_of_birth DATE, date_of_birth DATE,
gotcha_day DATE, gotcha_day DATE,
chart_color VARCHAR(7) NOT NULL DEFAULT '#cb3a35',
photo_data_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
ALTER TABLE birds ALTER TABLE birds
ADD COLUMN IF NOT EXISTS date_of_birth DATE, ADD COLUMN IF NOT EXISTS date_of_birth DATE,
ADD COLUMN IF NOT EXISTS gotcha_day DATE; ADD COLUMN IF NOT EXISTS gotcha_day DATE,
ADD COLUMN IF NOT EXISTS chart_color VARCHAR(7) NOT NULL DEFAULT '#cb3a35',
ADD COLUMN IF NOT EXISTS photo_data_url TEXT;
CREATE TABLE IF NOT EXISTS weight_records ( CREATE TABLE IF NOT EXISTS weight_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -197,15 +226,7 @@ const ensureSchema = async () => {
const getBirdById = async (birdId: string) => { const getBirdById = async (birdId: string) => {
const result = await pool.query<BirdRow>( const result = await pool.query<BirdRow>(
`SELECT `SELECT
birds.id, ${birdSelectFields}
birds.name,
birds.tag_id,
birds.species,
birds.date_of_birth::text,
birds.gotcha_day::text,
birds.created_at,
latest.weight_grams AS latest_weight_grams,
latest.recorded_on::text AS latest_recorded_on
FROM birds FROM birds
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT weight_grams, recorded_on SELECT weight_grams, recorded_on
@@ -229,15 +250,7 @@ app.get('/api/birds', async (_req: Request, res: Response, next: NextFunction) =
try { try {
const result = await pool.query<BirdRow>(` const result = await pool.query<BirdRow>(`
SELECT SELECT
birds.id, ${birdSelectFields}
birds.name,
birds.tag_id,
birds.species,
birds.date_of_birth::text,
birds.gotcha_day::text,
birds.created_at,
latest.weight_grams AS latest_weight_grams,
latest.recorded_on::text AS latest_recorded_on
FROM birds FROM birds
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT weight_grams, recorded_on SELECT weight_grams, recorded_on
@@ -265,15 +278,17 @@ app.post('/api/birds', async (req: Request, res: Response, next: NextFunction) =
try { try {
const result = await pool.query<BirdRow>( const result = await pool.query<BirdRow>(
`INSERT INTO birds (name, tag_id, species, date_of_birth, gotcha_day) `INSERT INTO birds (name, tag_id, species, date_of_birth, gotcha_day, chart_color, photo_data_url)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, name, tag_id, species, date_of_birth::text, gotcha_day::text, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`, RETURNING id, name, tag_id, species, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`,
[ [
parsed.data.name, parsed.data.name,
parsed.data.tagId, parsed.data.tagId,
parsed.data.species, parsed.data.species,
emptyToNull(parsed.data.dateOfBirth), emptyToNull(parsed.data.dateOfBirth),
emptyToNull(parsed.data.gotchaDay), emptyToNull(parsed.data.gotchaDay),
parsed.data.chartColor ?? '#cb3a35',
emptyToNull(parsed.data.photoDataUrl),
], ],
); );
@@ -288,6 +303,68 @@ app.post('/api/birds', async (req: Request, res: Response, next: NextFunction) =
} }
}); });
app.put('/api/birds/:birdId', async (req: Request, res: Response, next: NextFunction) => {
const parsed = birdSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid bird payload', details: parsed.error.flatten() });
return;
}
try {
const result = await pool.query<BirdRow>(
`UPDATE birds
SET name = $2,
tag_id = $3,
species = $4,
date_of_birth = $5,
gotcha_day = $6,
chart_color = $7,
photo_data_url = $8
WHERE id = $1
RETURNING id, name, tag_id, species, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, created_at,
(
SELECT weight_grams::text
FROM weight_records
WHERE bird_id = birds.id
ORDER BY recorded_on DESC
LIMIT 1
) AS latest_weight_grams,
(
SELECT recorded_on::text
FROM weight_records
WHERE bird_id = birds.id
ORDER BY recorded_on DESC
LIMIT 1
) AS latest_recorded_on`,
[
req.params.birdId,
parsed.data.name,
parsed.data.tagId,
parsed.data.species,
emptyToNull(parsed.data.dateOfBirth),
emptyToNull(parsed.data.gotchaDay),
parsed.data.chartColor ?? '#cb3a35',
emptyToNull(parsed.data.photoDataUrl),
],
);
if (!result.rowCount) {
res.status(404).json({ error: 'Bird not found.' });
return;
}
res.json({ bird: normalizeBird(result.rows[0]) });
} catch (error) {
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
res.status(409).json({ error: 'That tag ID is already in use.' });
return;
}
next(error);
}
});
app.delete('/api/birds/:birdId', async (req: Request, res: Response, next: NextFunction) => { app.delete('/api/birds/:birdId', async (req: Request, res: Response, next: NextFunction) => {
try { try {
const result = await pool.query<{ id: string }>('DELETE FROM birds WHERE id = $1 RETURNING id', [req.params.birdId]); const result = await pool.query<{ id: string }>('DELETE FROM birds WHERE id = $1 RETURNING id', [req.params.birdId]);
-1
View File
@@ -5,7 +5,6 @@ RUN npm install
COPY tsconfig*.json ./ COPY tsconfig*.json ./
COPY vite.config.ts ./ COPY vite.config.ts ./
COPY index.html ./ COPY index.html ./
COPY public ./public
COPY src ./src COPY src ./src
EXPOSE 3000 EXPOSE 3000
CMD ["npm", "run", "dev", "--", "--host"] CMD ["npm", "run", "dev", "--", "--host"]
+432 -76
View File
@@ -7,6 +7,8 @@ type Bird = {
species: string; species: string;
dateOfBirth: string | null; dateOfBirth: string | null;
gotchaDay: string | null; gotchaDay: string | null;
chartColor: string;
photoDataUrl: string | null;
createdAt: string; createdAt: string;
latestWeightGrams: number | null; latestWeightGrams: number | null;
latestRecordedOn: string | null; latestRecordedOn: string | null;
@@ -29,9 +31,40 @@ type VetVisit = {
notes: string | null; notes: string | null;
}; };
type BirdFormState = {
name: string;
tagId: string;
species: string;
dateOfBirth: string;
gotchaDay: string;
chartColor: string;
photoDataUrl: string;
};
type AppPage = 'overview' | 'flock' | 'settings'; type AppPage = 'overview' | 'flock' | 'settings';
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api'; const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api';
const emptyBirdForm: BirdFormState = {
name: '',
tagId: '',
species: '',
dateOfBirth: '',
gotchaDay: '',
chartColor: '#cb3a35',
photoDataUrl: '',
};
const sortBirdsByName = (nextBirds: Bird[]) => [...nextBirds].sort((left, right) => left.name.localeCompare(right.name));
const toBirdForm = (bird: Bird): BirdFormState => ({
name: bird.name,
tagId: bird.tagId,
species: bird.species,
dateOfBirth: bird.dateOfBirth ?? '',
gotchaDay: bird.gotchaDay ?? '',
chartColor: bird.chartColor,
photoDataUrl: bird.photoDataUrl ?? '',
});
const formatDate = (value: string | null) => { const formatDate = (value: string | null) => {
if (!value) { if (!value) {
@@ -57,6 +90,45 @@ const formatShortDate = (value: string | null) => {
}; };
const formatWeight = (value: number | null) => (value ? `${value.toFixed(1)} g` : 'Pending'); const formatWeight = (value: number | null) => (value ? `${value.toFixed(1)} g` : 'Pending');
const parseDateValue = (value: string) => new Date(`${value}T00:00:00`);
const OVERVIEW_WIDTH = 520;
const OVERVIEW_HEIGHT = 220;
const OVERVIEW_PADDING = { top: 20, right: 18, bottom: 36, left: 52 };
const readJsonSafely = async <T,>(response: Response): Promise<T | null> => {
const contentType = response.headers.get('content-type') ?? '';
if (!contentType.includes('application/json')) {
return null;
}
try {
return (await response.json()) as T;
} catch {
return null;
}
};
const readErrorMessage = async (response: Response, fallback: string) => {
const json = await readJsonSafely<{ error?: string }>(response);
if (json?.error) {
return json.error;
}
const text = await response.text();
const trimmed = text.trim();
if (!trimmed) {
return fallback;
}
if (trimmed.startsWith('<!DOCTYPE') || trimmed.startsWith('<html')) {
return fallback;
}
return trimmed;
};
const chartPath = (points: WeightRecord[], width = 520, height = 180) => { const chartPath = (points: WeightRecord[], width = 520, height = 180) => {
if (!points.length) { if (!points.length) {
@@ -95,30 +167,44 @@ const chartDots = (points: WeightRecord[], width = 520, height = 180) => {
})); }));
}; };
const birdLineStyles = [ const buildOverviewSeries = (points: WeightRecord[], minWeight: number, maxWeight: number, startDate: Date, endDate: Date) => {
{ stroke: '#cb3a35' }, const innerWidth = OVERVIEW_WIDTH - OVERVIEW_PADDING.left - OVERVIEW_PADDING.right;
{ stroke: '#238a5a' }, const innerHeight = OVERVIEW_HEIGHT - OVERVIEW_PADDING.top - OVERVIEW_PADDING.bottom;
{ stroke: '#2769b3' }, const weightSpread = Math.max(maxWeight - minWeight, 1);
{ stroke: '#f0b63f' }, const startMs = startDate.getTime();
{ stroke: '#2f8f98' }, const endMs = endDate.getTime();
]; const dateSpread = Math.max(endMs - startMs, 24 * 60 * 60 * 1000);
return points.map((point) => {
const pointTime = parseDateValue(point.recordedOn).getTime();
const x = OVERVIEW_PADDING.left + ((pointTime - startMs) / dateSpread) * innerWidth;
const y = OVERVIEW_PADDING.top + (1 - (point.weightGrams - minWeight) / weightSpread) * innerHeight;
return {
id: point.id,
x,
y,
label: `${point.weightGrams.toFixed(1)} g on ${formatShortDate(point.recordedOn)}`,
};
});
};
const toOverviewPath = (points: { x: number; y: number }[]) =>
points.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x.toFixed(1)} ${point.y.toFixed(1)}`).join(' ');
function App() { function App() {
const [activePage, setActivePage] = useState<AppPage>('overview'); const [activePage, setActivePage] = useState<AppPage>('overview');
const [birds, setBirds] = useState<Bird[]>([]); const [birds, setBirds] = useState<Bird[]>([]);
const [selectedBirdId, setSelectedBirdId] = useState<string>(''); const [selectedBirdId, setSelectedBirdId] = useState<string>('');
const [editingBirdId, setEditingBirdId] = useState<string>('');
const [weights, setWeights] = useState<WeightRecord[]>([]); const [weights, setWeights] = useState<WeightRecord[]>([]);
const [vetVisits, setVetVisits] = useState<VetVisit[]>([]); const [vetVisits, setVetVisits] = useState<VetVisit[]>([]);
const [allBirdWeights, setAllBirdWeights] = useState<Record<string, WeightRecord[]>>({}); const [allBirdWeights, setAllBirdWeights] = useState<Record<string, WeightRecord[]>>({});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [birdForm, setBirdForm] = useState({ const [birdForm, setBirdForm] = useState<BirdFormState>(emptyBirdForm);
name: '', const [birdPhotoName, setBirdPhotoName] = useState('');
tagId: '', const [savingBird, setSavingBird] = useState(false);
species: '',
dateOfBirth: '',
gotchaDay: '',
});
const [weightForm, setWeightForm] = useState({ const [weightForm, setWeightForm] = useState({
weightGrams: '', weightGrams: '',
recordedOn: new Date().toISOString().slice(0, 10), recordedOn: new Date().toISOString().slice(0, 10),
@@ -143,6 +229,10 @@ function App() {
() => birds.find((bird) => bird.id === selectedBirdId) ?? birds[0] ?? null, () => birds.find((bird) => bird.id === selectedBirdId) ?? birds[0] ?? null,
[birds, selectedBirdId], [birds, selectedBirdId],
); );
const editingBird = useMemo(
() => birds.find((bird) => bird.id === editingBirdId) ?? null,
[birds, editingBirdId],
);
const totalWeightEntries = useMemo( const totalWeightEntries = useMemo(
() => Object.values(allBirdWeights).reduce((total, entries) => total + entries.length, 0), () => Object.values(allBirdWeights).reduce((total, entries) => total + entries.length, 0),
@@ -172,12 +262,66 @@ function App() {
: `Weight is down ${Math.abs(delta).toFixed(1)} g over the current window.`; : `Weight is down ${Math.abs(delta).toFixed(1)} g over the current window.`;
}, [weights]); }, [weights]);
const overviewChart = useMemo(() => {
const plottedBirds = birds
.map((bird) => ({ bird, weights: allBirdWeights[bird.id] ?? [] }))
.filter((entry) => entry.weights.length > 0);
const endDate = new Date();
endDate.setHours(0, 0, 0, 0);
const startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - 29);
if (!plottedBirds.length) {
return {
plottedBirds,
series: [],
xTicks: [
{ label: formatShortDate(startDate.toISOString().slice(0, 10)), x: OVERVIEW_PADDING.left },
{ label: formatShortDate(endDate.toISOString().slice(0, 10)), x: OVERVIEW_WIDTH - OVERVIEW_PADDING.right },
],
yTicks: [],
};
}
const allWeights = plottedBirds.flatMap((entry) => entry.weights.map((weight) => weight.weightGrams));
const rawMinWeight = Math.min(...allWeights);
const rawMaxWeight = Math.max(...allWeights);
const weightPadding = Math.max((rawMaxWeight - rawMinWeight) * 0.12, 2);
const minWeight = Math.max(0, rawMinWeight - weightPadding);
const maxWeight = rawMaxWeight + weightPadding;
const midWeight = minWeight + (maxWeight - minWeight) / 2;
return {
plottedBirds,
series: plottedBirds.map(({ bird, weights: birdWeights }) => ({
bird,
points: buildOverviewSeries(birdWeights, minWeight, maxWeight, startDate, endDate),
})),
xTicks: [
{ label: formatShortDate(startDate.toISOString().slice(0, 10)), x: OVERVIEW_PADDING.left },
{ label: formatShortDate(new Date((startDate.getTime() + endDate.getTime()) / 2).toISOString().slice(0, 10)), x: OVERVIEW_WIDTH / 2 },
{ label: formatShortDate(endDate.toISOString().slice(0, 10)), x: OVERVIEW_WIDTH - OVERVIEW_PADDING.right },
],
yTicks: [
{ label: `${maxWeight.toFixed(0)} g`, y: OVERVIEW_PADDING.top },
{ label: `${midWeight.toFixed(0)} g`, y: OVERVIEW_PADDING.top + (OVERVIEW_HEIGHT - OVERVIEW_PADDING.top - OVERVIEW_PADDING.bottom) / 2 },
{ label: `${minWeight.toFixed(0)} g`, y: OVERVIEW_HEIGHT - OVERVIEW_PADDING.bottom },
],
};
}, [allBirdWeights, birds]);
useEffect(() => { useEffect(() => {
const loadBirds = async () => { const loadBirds = async () => {
try { try {
setLoading(true); setLoading(true);
const response = await fetch(`${apiBaseUrl}/birds`); const response = await fetch(`${apiBaseUrl}/birds`);
const data = await response.json();
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to load flock members.'));
}
const data = (await readJsonSafely<{ birds?: Bird[] }>(response)) ?? {};
const nextBirds = data.birds ?? []; const nextBirds = data.birds ?? [];
setBirds(nextBirds); setBirds(nextBirds);
@@ -205,8 +349,13 @@ function App() {
fetch(`${apiBaseUrl}/birds/${selectedBird.id}/weights?days=90`), fetch(`${apiBaseUrl}/birds/${selectedBird.id}/weights?days=90`),
fetch(`${apiBaseUrl}/birds/${selectedBird.id}/vet-visits`), fetch(`${apiBaseUrl}/birds/${selectedBird.id}/vet-visits`),
]); ]);
const weightsData = await weightsResponse.json();
const visitsData = await visitsResponse.json(); if (!weightsResponse.ok || !visitsResponse.ok) {
throw new Error('Unable to load flock member details.');
}
const weightsData = (await readJsonSafely<{ weights?: WeightRecord[] }>(weightsResponse)) ?? {};
const visitsData = (await readJsonSafely<{ vetVisits?: VetVisit[] }>(visitsResponse)) ?? {};
setWeights(weightsData.weights ?? []); setWeights(weightsData.weights ?? []);
setVetVisits(visitsData.vetVisits ?? []); setVetVisits(visitsData.vetVisits ?? []);
@@ -229,7 +378,12 @@ function App() {
const responses = await Promise.all( const responses = await Promise.all(
birds.map(async (bird) => { birds.map(async (bird) => {
const response = await fetch(`${apiBaseUrl}/birds/${bird.id}/weights?days=30`); const response = await fetch(`${apiBaseUrl}/birds/${bird.id}/weights?days=30`);
const data = await response.json();
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to load overview weights.'));
}
const data = (await readJsonSafely<{ weights?: WeightRecord[] }>(response)) ?? {};
return [bird.id, (data.weights ?? []) as WeightRecord[]] as const; return [bird.id, (data.weights ?? []) as WeightRecord[]] as const;
}), }),
); );
@@ -243,35 +397,117 @@ function App() {
void loadAllBirdWeights(); void loadAllBirdWeights();
}, [birds]); }, [birds]);
useEffect(() => {
if (!editingBirdId) {
return;
}
if (!editingBird) {
setEditingBirdId('');
setBirdForm(emptyBirdForm);
setBirdPhotoName('');
return;
}
setBirdForm(toBirdForm(editingBird));
setBirdPhotoName('');
}, [editingBird, editingBirdId]);
const startCreateBird = () => {
setEditingBirdId('');
setBirdForm(emptyBirdForm);
setBirdPhotoName('');
setError('');
setActivePage('settings');
};
const startEditBird = (bird: Bird) => {
setSelectedBirdId(bird.id);
setEditingBirdId(bird.id);
setBirdForm(toBirdForm(bird));
setBirdPhotoName('');
setError('');
setActivePage('settings');
};
const handleBirdPhotoChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) {
return;
}
if (file.size > 900_000) {
setError('Photo is too large. Please choose an image under 900 KB.');
event.target.value = '';
return;
}
try {
const photoDataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '');
reader.onerror = () => reject(new Error('Unable to read that photo.'));
reader.readAsDataURL(file);
});
setBirdForm((current) => ({ ...current, photoDataUrl }));
setBirdPhotoName(file.name);
setError('');
} catch (photoError) {
setError(photoError instanceof Error ? photoError.message : 'Unable to read that photo.');
} finally {
event.target.value = '';
}
};
const handleRemovePhoto = () => {
setBirdForm((current) => ({ ...current, photoDataUrl: '' }));
setBirdPhotoName('');
};
const handleBirdSubmit = async (event: React.FormEvent<HTMLFormElement>) => { const handleBirdSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
setError(''); setError('');
setSavingBird(true);
const isEditing = Boolean(editingBirdId);
const endpoint = isEditing ? `${apiBaseUrl}/birds/${editingBirdId}` : `${apiBaseUrl}/birds`;
const method = isEditing ? 'PUT' : 'POST';
try { try {
const response = await fetch(`${apiBaseUrl}/birds`, { const response = await fetch(endpoint, {
method: 'POST', method,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(birdForm), body: JSON.stringify(birdForm),
}); });
if (!response.ok) { if (!response.ok) {
const data = await response.json(); throw new Error(await readErrorMessage(response, `Unable to ${isEditing ? 'update' : 'create'} flock member.`));
throw new Error(data.error ?? 'Unable to create flock member.');
} }
const data = await response.json(); const data = await readJsonSafely<{ bird: Bird }>(response);
setBirds((current) => [...current, data.bird].sort((left, right) => left.name.localeCompare(right.name))); if (!data?.bird) {
setSelectedBirdId(data.bird.id); throw new Error(`Unable to ${isEditing ? 'update' : 'create'} flock member.`);
setBirdForm({ }
name: '', const savedBird = data.bird as Bird;
tagId: '',
species: '', setBirds((current) => {
dateOfBirth: '', if (isEditing) {
gotchaDay: '', return sortBirdsByName(current.map((bird) => (bird.id === savedBird.id ? savedBird : bird)));
}
return sortBirdsByName([...current, savedBird]);
}); });
setActivePage('flock'); setSelectedBirdId(savedBird.id);
setEditingBirdId(savedBird.id);
setBirdForm(toBirdForm(savedBird));
setBirdPhotoName('');
setActivePage(isEditing ? 'settings' : 'flock');
} catch (submitError) { } catch (submitError) {
setError(submitError instanceof Error ? submitError.message : 'Unable to create flock member.'); setError(submitError instanceof Error ? submitError.message : 'Unable to save flock member.');
} finally {
setSavingBird(false);
} }
}; };
@@ -296,11 +532,13 @@ function App() {
}); });
if (!response.ok) { if (!response.ok) {
const data = await response.json(); throw new Error(await readErrorMessage(response, 'Unable to save weight.'));
throw new Error(data.error ?? 'Unable to save weight.');
} }
const data = await response.json(); const data = await readJsonSafely<{ weight: WeightRecord }>(response);
if (!data?.weight) {
throw new Error('Unable to save weight.');
}
const nextWeights = [...weights, data.weight].sort((left, right) => left.recordedOn.localeCompare(right.recordedOn)); const nextWeights = [...weights, data.weight].sort((left, right) => left.recordedOn.localeCompare(right.recordedOn));
setWeights(nextWeights); setWeights(nextWeights);
@@ -346,11 +584,13 @@ function App() {
}); });
if (!response.ok) { if (!response.ok) {
const data = await response.json(); throw new Error(await readErrorMessage(response, 'Unable to save vet visit.'));
throw new Error(data.error ?? 'Unable to save vet visit.');
} }
const data = await response.json(); const data = await readJsonSafely<{ vetVisit: VetVisit }>(response);
if (!data?.vetVisit) {
throw new Error('Unable to save vet visit.');
}
setVetVisits((current) => setVetVisits((current) =>
[data.vetVisit, ...current].sort((left, right) => right.visitedOn.localeCompare(left.visitedOn)), [data.vetVisit, ...current].sort((left, right) => right.visitedOn.localeCompare(left.visitedOn)),
); );
@@ -387,8 +627,7 @@ function App() {
}); });
if (!response.ok) { if (!response.ok) {
const data = await response.json(); throw new Error(await readErrorMessage(response, 'Unable to remove flock member.'));
throw new Error(data.error ?? 'Unable to remove flock member.');
} }
const nextBirds = birds.filter((bird) => bird.id !== selectedBird.id); const nextBirds = birds.filter((bird) => bird.id !== selectedBird.id);
@@ -401,6 +640,12 @@ function App() {
setSelectedBirdId(nextBirds[0]?.id ?? ''); setSelectedBirdId(nextBirds[0]?.id ?? '');
setWeights([]); setWeights([]);
setVetVisits([]); setVetVisits([]);
if (editingBirdId === selectedBird.id) {
setEditingBirdId('');
setBirdForm(emptyBirdForm);
setBirdPhotoName('');
}
} catch (removeError) { } catch (removeError) {
setError(removeError instanceof Error ? removeError.message : 'Unable to remove flock member.'); setError(removeError instanceof Error ? removeError.message : 'Unable to remove flock member.');
} finally { } finally {
@@ -435,10 +680,11 @@ function App() {
return ( return (
<main className="app-shell"> <main className="app-shell">
<section className="hero-card"> <aside className="side-nav panel">
<div> <div>
<p className="eyebrow">Bird health tracker</p> <p className="eyebrow">Bird health tracker</p>
<h1>FlockPal dashboard</h1> <h2>FlockPal</h2>
</div>
<div className="page-tabs" role="tablist" aria-label="Main navigation"> <div className="page-tabs" role="tablist" aria-label="Main navigation">
<button className={`page-tab ${activePage === 'overview' ? 'active' : ''}`} onClick={() => setActivePage('overview')} type="button"> <button className={`page-tab ${activePage === 'overview' ? 'active' : ''}`} onClick={() => setActivePage('overview')} type="button">
Overview Overview
@@ -450,6 +696,13 @@ function App() {
Settings Settings
</button> </button>
</div> </div>
</aside>
<section className="content-shell">
<section className="hero-card">
<div>
<p className="eyebrow">Dashboard</p>
<h1>FlockPal dashboard</h1>
</div> </div>
<div className="hero-stats"> <div className="hero-stats">
<article> <article>
@@ -482,42 +735,54 @@ function App() {
<div className="chart-card overview-chart-card"> <div className="chart-card overview-chart-card">
<svg viewBox="0 0 520 220" className="weight-chart" role="img" aria-label="All birds weight overview"> <svg viewBox="0 0 520 220" className="weight-chart" role="img" aria-label="All birds weight overview">
{birds.map((bird, index) => { {overviewChart.yTicks.map((tick) => (
const birdWeights = allBirdWeights[bird.id] ?? []; <g key={tick.label}>
const style = birdLineStyles[index % birdLineStyles.length]; <line
const dots = chartDots(birdWeights, 520, 220); x1={OVERVIEW_PADDING.left}
y1={tick.y}
if (!birdWeights.length) { x2={OVERVIEW_WIDTH - OVERVIEW_PADDING.right}
return null; y2={tick.y}
} className="chart-grid-line"
/>
return ( <text x={OVERVIEW_PADDING.left - 10} y={tick.y + 4} textAnchor="end" className="chart-axis-label">
{tick.label}
</text>
</g>
))}
<line
x1={OVERVIEW_PADDING.left}
y1={OVERVIEW_HEIGHT - OVERVIEW_PADDING.bottom}
x2={OVERVIEW_WIDTH - OVERVIEW_PADDING.right}
y2={OVERVIEW_HEIGHT - OVERVIEW_PADDING.bottom}
className="chart-axis-line"
/>
{overviewChart.xTicks.map((tick) => (
<text key={tick.label} x={tick.x} y={OVERVIEW_HEIGHT - 10} textAnchor="middle" className="chart-axis-label">
{tick.label}
</text>
))}
{overviewChart.series.map(({ bird, points }) => (
<g key={bird.id}> <g key={bird.id}>
<path d={chartPath(birdWeights, 520, 220)} fill="none" stroke={style.stroke} strokeWidth="3.5" strokeLinecap="round" /> {points.length > 1 ? (
{dots.map((dot) => ( <path d={toOverviewPath(points)} fill="none" stroke={bird.chartColor} strokeWidth="3.5" strokeLinecap="round" />
<circle key={dot.id} cx={dot.x} cy={dot.y} r="4.5" fill={style.stroke}> ) : null}
<title>{`${bird.name}: ${dot.label}`}</title> {points.map((point) => (
<circle key={point.id} cx={point.x} cy={point.y} r="4.5" fill={bird.chartColor}>
<title>{`${bird.name}: ${point.label}`}</title>
</circle> </circle>
))} ))}
</g> </g>
); ))}
})}
</svg> </svg>
</div> </div>
<div className="legend-grid"> <div className="legend-grid">
{birds.map((bird, index) => { {overviewChart.plottedBirds.map(({ bird }) => {
const style = birdLineStyles[index % birdLineStyles.length];
const birdWeights = allBirdWeights[bird.id] ?? [];
return ( return (
<article key={bird.id} className="legend-card"> <article key={bird.id} className="legend-card">
<span className="legend-swatch" style={{ background: style.stroke }} /> <span className="legend-swatch" style={{ background: bird.chartColor }} />
<div> <div>
<strong>{bird.name}</strong> <strong>{bird.name}</strong>
<small>
{bird.species} {birdWeights.length ? `${birdWeights.length} entries` : 'No entries yet'}
</small>
</div> </div>
</article> </article>
); );
@@ -575,7 +840,6 @@ function App() {
) : null} ) : null}
{activePage === 'flock' ? ( {activePage === 'flock' ? (
<>
<section className="dashboard-grid"> <section className="dashboard-grid">
<aside className="panel bird-list-panel"> <aside className="panel bird-list-panel">
<div className="panel-header"> <div className="panel-header">
@@ -583,6 +847,9 @@ function App() {
<p className="eyebrow">Flock</p> <p className="eyebrow">Flock</p>
<h2>Flock members</h2> <h2>Flock members</h2>
</div> </div>
<button className="secondary-button" onClick={startCreateBird} type="button">
Add bird
</button>
</div> </div>
<div className="bird-list"> <div className="bird-list">
{birds.map((bird) => ( {birds.map((bird) => (
@@ -592,10 +859,21 @@ function App() {
onClick={() => setSelectedBirdId(bird.id)} onClick={() => setSelectedBirdId(bird.id)}
type="button" type="button"
> >
<div className="bird-card-header">
{bird.photoDataUrl ? (
<img className="bird-avatar" src={bird.photoDataUrl} alt={`${bird.name}`} />
) : (
<div className="bird-avatar placeholder-avatar" aria-hidden="true">
{bird.name.slice(0, 1).toUpperCase()}
</div>
)}
<div>
<span>{bird.name}</span> <span>{bird.name}</span>
<small> <small>
{bird.species} {bird.tagId} {bird.species} {bird.tagId}
</small> </small>
</div>
</div>
<strong>{formatWeight(bird.latestWeightGrams)}</strong> <strong>{formatWeight(bird.latestWeightGrams)}</strong>
</button> </button>
))} ))}
@@ -609,14 +887,37 @@ function App() {
<h2>{selectedBird ? selectedBird.name : 'Choose a flock member'}</h2> <h2>{selectedBird ? selectedBird.name : 'Choose a flock member'}</h2>
</div> </div>
{selectedBird ? ( {selectedBird ? (
<div className="button-row">
<button className="secondary-button" onClick={() => startEditBird(selectedBird)} type="button">
Edit details
</button>
<button className="danger-button" onClick={handleRemoveBird} type="button" disabled={deletingBird}> <button className="danger-button" onClick={handleRemoveBird} type="button" disabled={deletingBird}>
{deletingBird ? 'Removing...' : 'Remove from flock'} {deletingBird ? 'Removing...' : 'Remove from flock'}
</button> </button>
</div>
) : null} ) : null}
</div> </div>
{selectedBird ? ( {selectedBird ? (
<> <>
<section className="profile-hero">
{selectedBird.photoDataUrl ? (
<img className="profile-photo" src={selectedBird.photoDataUrl} alt={`${selectedBird.name}`} />
) : (
<div className="profile-photo placeholder-avatar" aria-hidden="true">
{selectedBird.name.slice(0, 1).toUpperCase()}
</div>
)}
<div className="profile-copy">
<p className="eyebrow">Profile</p>
<h3>{selectedBird.name}</h3>
<p className="muted">
{selectedBird.species} Band {selectedBird.tagId}
</p>
<p className="muted">Added {formatDate(selectedBird.createdAt.slice(0, 10))}</p>
</div>
</section>
<div className="detail-grid"> <div className="detail-grid">
<article className="detail-card"> <article className="detail-card">
<span>Name</span> <span>Name</span>
@@ -657,10 +958,8 @@ function App() {
<svg viewBox="0 0 520 180" className="weight-chart" role="img" aria-label="Selected flock member weight trend chart"> <svg viewBox="0 0 520 180" className="weight-chart" role="img" aria-label="Selected flock member weight trend chart">
<defs> <defs>
<linearGradient id="lineGlow" x1="0%" y1="0%" x2="100%" y2="0%"> <linearGradient id="lineGlow" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#cb3a35" /> <stop offset="0%" stopColor={selectedBird.chartColor} stopOpacity="0.45" />
<stop offset="38%" stopColor="#f0b63f" /> <stop offset="100%" stopColor={selectedBird.chartColor} />
<stop offset="68%" stopColor="#238a5a" />
<stop offset="100%" stopColor="#2769b3" />
</linearGradient> </linearGradient>
</defs> </defs>
<path d={chartPath(weights)} fill="none" stroke="url(#lineGlow)" strokeWidth="4" strokeLinecap="round" /> <path d={chartPath(weights)} fill="none" stroke="url(#lineGlow)" strokeWidth="4" strokeLinecap="round" />
@@ -781,7 +1080,6 @@ function App() {
)} )}
</section> </section>
</section> </section>
</>
) : null} ) : null}
{activePage === 'settings' ? ( {activePage === 'settings' ? (
@@ -790,9 +1088,33 @@ function App() {
<div className="panel-header"> <div className="panel-header">
<div> <div>
<p className="eyebrow">Flock setup</p> <p className="eyebrow">Flock setup</p>
<h2>Add a flock member</h2> <h2>{editingBird ? `Edit ${editingBird.name}` : 'Add a flock member'}</h2>
</div>
<div className="button-row">
{selectedBird ? (
<button className="secondary-button" onClick={() => startEditBird(selectedBird)} type="button">
Edit selected
</button>
) : null}
<button className="secondary-button" onClick={startCreateBird} type="button">
New bird
</button>
</div> </div>
</div> </div>
<div className="picker-list">
{birds.map((bird) => (
<button
key={bird.id}
className={`picker-chip ${editingBirdId === bird.id ? 'active' : ''}`}
onClick={() => startEditBird(bird)}
type="button"
>
{bird.name}
</button>
))}
</div>
<form className="form-panel" onSubmit={handleBirdSubmit}> <form className="form-panel" onSubmit={handleBirdSubmit}>
<label> <label>
Bird name Bird name
@@ -822,8 +1144,41 @@ function App() {
onChange={(event) => setBirdForm({ ...birdForm, gotchaDay: event.target.value })} onChange={(event) => setBirdForm({ ...birdForm, gotchaDay: event.target.value })}
/> />
</label> </label>
<button className="primary-button" type="submit"> <label>
Save flock member Graph color
<input type="color" value={birdForm.chartColor} onChange={(event) => setBirdForm({ ...birdForm, chartColor: event.target.value })} />
</label>
<div className="color-preview-card">
<span className="legend-swatch large-swatch" style={{ background: birdForm.chartColor }} />
<p className="muted">This color will follow this bird across the overview graph and its individual weight trend.</p>
</div>
<div className="photo-editor">
<div className="photo-preview-shell">
{birdForm.photoDataUrl ? (
<img className="profile-photo" src={birdForm.photoDataUrl} alt="Bird preview" />
) : (
<div className="profile-photo placeholder-avatar" aria-hidden="true">
{(birdForm.name || 'B').slice(0, 1).toUpperCase()}
</div>
)}
</div>
<div className="photo-copy">
<label className="file-picker">
Photo
<input accept="image/png,image/jpeg,image/jpg,image/webp,image/gif" onChange={handleBirdPhotoChange} type="file" />
</label>
<p className="muted">{birdPhotoName || (birdForm.photoDataUrl ? 'Current photo ready to save.' : 'Optional. Great for quick identification.')}</p>
{birdForm.photoDataUrl ? (
<button className="secondary-button" onClick={handleRemovePhoto} type="button">
Remove photo
</button>
) : null}
</div>
</div>
<button className="primary-button" type="submit" disabled={savingBird}>
{savingBird ? 'Saving...' : editingBird ? 'Save changes' : 'Save flock member'}
</button> </button>
</form> </form>
</article> </article>
@@ -872,6 +1227,7 @@ function App() {
</article> </article>
</section> </section>
) : null} ) : null}
</section>
</main> </main>
); );
} }
+204 -45
View File
@@ -11,10 +11,12 @@
--accent-teal: #2f8f98; --accent-teal: #2f8f98;
color: var(--ink); color: var(--ink);
background: background:
radial-gradient(circle at top left, rgba(203, 58, 53, 0.24), transparent 24%), radial-gradient(circle at 14% 10%, rgba(222, 124, 58, 0.28), transparent 22%),
radial-gradient(circle at 88% 22%, rgba(39, 105, 179, 0.22), transparent 20%), radial-gradient(circle at 82% 12%, rgba(53, 136, 110, 0.26), transparent 20%),
radial-gradient(circle at bottom right, rgba(35, 138, 90, 0.24), transparent 28%), radial-gradient(circle at 24% 84%, rgba(221, 179, 78, 0.2), transparent 22%),
linear-gradient(180deg, #fff3e8 0%, #f6ead7 55%, #eef7f2 100%); radial-gradient(circle at 86% 78%, rgba(43, 118, 92, 0.24), transparent 24%),
radial-gradient(circle at 62% 54%, rgba(48, 114, 160, 0.14), transparent 16%),
linear-gradient(180deg, #fef5e7 0%, #e9ddba 46%, #d9eadf 100%);
font-family: "Avenir Next", "Segoe UI", sans-serif; font-family: "Avenir Next", "Segoe UI", sans-serif;
line-height: 1.5; line-height: 1.5;
font-weight: 400; font-weight: 400;
@@ -34,11 +36,27 @@ body,
body { body {
min-height: 100vh; min-height: 100vh;
color: var(--ink); color: var(--ink);
position: relative;
isolation: isolate;
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: -1;
opacity: 0.32;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 720 720'%3E%3Crect width='720' height='720' fill='none'/%3E%3Cg fill='none' stroke-linecap='round' stroke-linejoin='round' stroke-width='16'%3E%3Cg stroke='%236db7b4' transform='translate(72 88) rotate(-18)'%3E%3Cpath d='M0 0 L-24 -42'/%3E%3Cpath d='M0 0 L12 -50'/%3E%3Cpath d='M0 0 L44 -18'/%3E%3C/g%3E%3Cg stroke='%2396c489' transform='translate(248 70) rotate(14)'%3E%3Cpath d='M0 0 L-28 -38'/%3E%3Cpath d='M0 0 L4 -52'/%3E%3Cpath d='M0 0 L40 -30'/%3E%3C/g%3E%3Cg stroke='%23f5a347' transform='translate(430 92) rotate(-10)'%3E%3Cpath d='M0 0 L-30 -32'/%3E%3Cpath d='M0 0 L10 -50'/%3E%3Cpath d='M0 0 L46 -14'/%3E%3C/g%3E%3Cg stroke='%23888690' transform='translate(604 64) rotate(20)'%3E%3Cpath d='M0 0 L-26 -36'/%3E%3Cpath d='M0 0 L2 -54'/%3E%3Cpath d='M0 0 L36 -34'/%3E%3C/g%3E%3Cg stroke='%23b5c948' transform='translate(126 220) rotate(8)'%3E%3Cpath d='M0 0 L-22 -46'/%3E%3Cpath d='M0 0 L6 -54'/%3E%3Cpath d='M0 0 L42 -26'/%3E%3C/g%3E%3Cg stroke='%236db7b4' transform='translate(330 246) rotate(-24)'%3E%3Cpath d='M0 0 L-28 -34'/%3E%3Cpath d='M0 0 L0 -56'/%3E%3Cpath d='M0 0 L38 -30'/%3E%3C/g%3E%3Cg stroke='%23888690' transform='translate(520 224) rotate(18)'%3E%3Cpath d='M0 0 L-20 -46'/%3E%3Cpath d='M0 0 L6 -52'/%3E%3Cpath d='M0 0 L34 -36'/%3E%3C/g%3E%3Cg stroke='%2396c489' transform='translate(654 220) rotate(-14)'%3E%3Cpath d='M0 0 L-34 -26'/%3E%3Cpath d='M0 0 L-4 -54'/%3E%3Cpath d='M0 0 L34 -38'/%3E%3C/g%3E%3Cg stroke='%23f5a347' transform='translate(62 402) rotate(22)'%3E%3Cpath d='M0 0 L-30 -28'/%3E%3Cpath d='M0 0 L2 -54'/%3E%3Cpath d='M0 0 L38 -26'/%3E%3C/g%3E%3Cg stroke='%23888690' transform='translate(250 386) rotate(-18)'%3E%3Cpath d='M0 0 L-24 -42'/%3E%3Cpath d='M0 0 L10 -50'/%3E%3Cpath d='M0 0 L40 -22'/%3E%3C/g%3E%3Cg stroke='%2396c489' transform='translate(438 410) rotate(10)'%3E%3Cpath d='M0 0 L-28 -38'/%3E%3Cpath d='M0 0 L4 -54'/%3E%3Cpath d='M0 0 L44 -24'/%3E%3C/g%3E%3Cg stroke='%236db7b4' transform='translate(610 392) rotate(-20)'%3E%3Cpath d='M0 0 L-26 -34'/%3E%3Cpath d='M0 0 L8 -54'/%3E%3Cpath d='M0 0 L40 -18'/%3E%3C/g%3E%3Cg stroke='%23b5c948' transform='translate(122 564) rotate(16)'%3E%3Cpath d='M0 0 L-30 -30'/%3E%3Cpath d='M0 0 L2 -56'/%3E%3Cpath d='M0 0 L44 -20'/%3E%3C/g%3E%3Cg stroke='%23888690' transform='translate(330 576) rotate(-12)'%3E%3Cpath d='M0 0 L-26 -40'/%3E%3Cpath d='M0 0 L10 -52'/%3E%3Cpath d='M0 0 L40 -28'/%3E%3C/g%3E%3Cg stroke='%23f5a347' transform='translate(520 566) rotate(24)'%3E%3Cpath d='M0 0 L-28 -34'/%3E%3Cpath d='M0 0 L2 -54'/%3E%3Cpath d='M0 0 L42 -24'/%3E%3C/g%3E%3Cg stroke='%2396c489' transform='translate(678 586) rotate(-18)'%3E%3Cpath d='M0 0 L-26 -38'/%3E%3Cpath d='M0 0 L6 -54'/%3E%3Cpath d='M0 0 L36 -28'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
background-position: center center;
background-repeat: repeat;
background-size: 360px 360px;
} }
button, button,
input, input,
textarea { textarea,
select {
font: inherit; font: inherit;
} }
@@ -51,7 +69,8 @@ button:disabled {
} }
input, input,
textarea { textarea,
select {
width: 100%; width: 100%;
margin-top: 0.5rem; margin-top: 0.5rem;
border: 1px solid rgba(39, 105, 179, 0.16); border: 1px solid rgba(39, 105, 179, 0.16);
@@ -62,7 +81,8 @@ textarea {
} }
input:focus, input:focus,
textarea:focus { textarea:focus,
select:focus {
outline: none; outline: none;
border-color: rgba(39, 105, 179, 0.62); border-color: rgba(39, 105, 179, 0.62);
box-shadow: 0 0 0 4px rgba(39, 105, 179, 0.14); box-shadow: 0 0 0 4px rgba(39, 105, 179, 0.14);
@@ -73,11 +93,25 @@ textarea {
} }
.app-shell { .app-shell {
max-width: 1280px; max-width: 1400px;
margin: 0 auto; margin: 0 auto;
padding: 2rem; padding: 2rem;
display: grid; display: grid;
grid-template-columns: 240px minmax(0, 1fr);
gap: 1.5rem; gap: 1.5rem;
align-items: start;
}
.content-shell {
display: grid;
gap: 1.5rem;
}
.side-nav {
position: sticky;
top: 2rem;
display: grid;
gap: 1.25rem;
} }
.stack-grid { .stack-grid {
@@ -107,22 +141,6 @@ textarea {
overflow: hidden; 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, .hero-card h1,
.panel h2 { .panel h2 {
margin: 0; margin: 0;
@@ -135,21 +153,18 @@ textarea {
} }
.page-tabs { .page-tabs {
display: flex; display: grid;
gap: 0.75rem; gap: 0.75rem;
flex-wrap: wrap;
margin-top: 1.25rem;
position: relative;
z-index: 1;
} }
.page-tab { .page-tab {
border: 1px solid rgba(39, 105, 179, 0.14); border: 1px solid rgba(39, 105, 179, 0.14);
border-radius: 999px; border-radius: 18px;
padding: 0.7rem 1.1rem; padding: 0.95rem 1rem;
background: rgba(255, 255, 255, 0.54); background: rgba(255, 255, 255, 0.54);
color: var(--ink); color: var(--ink);
transition: transform 0.2s ease, background 0.2s ease, border-color 0.2s ease; transition: transform 0.2s ease, background 0.2s ease, border-color 0.2s ease;
text-align: left;
} }
.page-tab.active { .page-tab.active {
@@ -265,11 +280,49 @@ textarea {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.button-row {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.bird-list { .bird-list {
display: grid; display: grid;
gap: 0.9rem; gap: 0.9rem;
} }
.bird-card-header {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.85rem;
align-items: center;
}
.bird-avatar,
.profile-photo {
width: 56px;
height: 56px;
object-fit: cover;
border-radius: 18px;
border: 1px solid rgba(39, 105, 179, 0.16);
background: rgba(255, 255, 255, 0.86);
}
.profile-photo {
width: 112px;
height: 112px;
border-radius: 28px;
}
.placeholder-avatar {
display: grid;
place-items: center;
background: linear-gradient(135deg, rgba(203, 58, 53, 0.14), rgba(39, 105, 179, 0.18));
color: var(--accent-red);
font-size: 1.6rem;
font-weight: 700;
}
.bird-card { .bird-card {
width: 100%; width: 100%;
text-align: left; text-align: left;
@@ -311,17 +364,8 @@ textarea {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.chart-card::after { .overview-chart-card::before {
content: ""; display: none;
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 { .overview-chart-card {
@@ -334,6 +378,22 @@ textarea {
min-height: 180px; min-height: 180px;
} }
.chart-grid-line {
stroke: rgba(39, 105, 179, 0.16);
stroke-width: 1;
stroke-dasharray: 4 6;
}
.chart-axis-line {
stroke: rgba(31, 42, 42, 0.24);
stroke-width: 1.2;
}
.chart-axis-label {
fill: var(--muted);
font-size: 11px;
}
.chart-footer, .chart-footer,
.recent-list, .recent-list,
.detail-grid, .detail-grid,
@@ -361,6 +421,27 @@ textarea {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
} }
.profile-hero {
display: grid;
grid-template-columns: auto 1fr;
gap: 1rem;
align-items: center;
padding: 1rem;
border-radius: 24px;
background: linear-gradient(180deg, rgba(255, 252, 247, 0.92), rgba(240, 248, 244, 0.84));
border: 1px solid rgba(39, 105, 179, 0.1);
}
.profile-copy {
display: grid;
gap: 0.3rem;
}
.profile-copy h3 {
margin: 0;
font-size: 1.6rem;
}
.inline-form { .inline-form {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
@@ -399,6 +480,11 @@ textarea {
box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.65); box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.65);
} }
.large-swatch {
width: 24px;
height: 24px;
}
.detail-card span, .detail-card span,
.summary-card span { .summary-card span {
color: var(--muted); color: var(--muted);
@@ -443,6 +529,19 @@ label {
background: linear-gradient(135deg, #b7312d, #1f5e9f); background: linear-gradient(135deg, #b7312d, #1f5e9f);
} }
.secondary-button {
border: 1px solid rgba(39, 105, 179, 0.18);
border-radius: 18px;
padding: 0.95rem 1.2rem;
color: var(--ink);
background: rgba(255, 255, 255, 0.72);
box-shadow: 0 10px 22px rgba(39, 105, 179, 0.1);
}
.secondary-button:hover {
background: rgba(255, 255, 255, 0.92);
}
.danger-button { .danger-button {
border: 1px solid rgba(171, 44, 44, 0.18); border: 1px solid rgba(171, 44, 44, 0.18);
border-radius: 18px; border-radius: 18px;
@@ -470,13 +569,73 @@ label {
color: #922728; color: #922728;
} }
.picker-list {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.color-preview-card {
display: flex;
gap: 0.9rem;
align-items: center;
padding: 0.95rem 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);
}
.picker-chip {
border: 1px solid rgba(39, 105, 179, 0.16);
border-radius: 999px;
padding: 0.65rem 0.95rem;
background: rgba(255, 255, 255, 0.68);
color: var(--ink);
}
.picker-chip.active {
border-color: transparent;
background: linear-gradient(135deg, rgba(203, 58, 53, 0.94), rgba(39, 105, 179, 0.94));
color: #fffdf9;
}
.photo-editor {
display: grid;
grid-template-columns: auto 1fr;
gap: 1rem;
align-items: center;
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);
}
.photo-preview-shell {
display: flex;
align-items: center;
justify-content: center;
}
.photo-copy {
display: grid;
gap: 0.75rem;
}
.file-picker input[type="file"] {
margin-top: 0.75rem;
padding: 0.75rem;
}
@media (max-width: 980px) { @media (max-width: 980px) {
.app-shell,
.hero-card, .hero-card,
.dashboard-grid, .dashboard-grid,
.forms-grid, .forms-grid,
.hero-stats, .hero-stats,
.chart-footer, .chart-footer,
.inline-form { .inline-form,
.profile-hero,
.photo-editor {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -484,7 +643,7 @@ label {
padding: 1rem; padding: 1rem;
} }
.page-tabs { .side-nav {
margin-top: 1rem; position: static;
} }
} }