visual improvements
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node --import tsx src/app.ts",
|
||||
"dev": "tsx watch src/app.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/app.js"
|
||||
},
|
||||
|
||||
+100
-23
@@ -39,6 +39,11 @@ const allowedOrigins = Array.from(
|
||||
);
|
||||
|
||||
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({
|
||||
name: z.string().trim().min(1).max(120),
|
||||
@@ -46,6 +51,8 @@ const birdSchema = z.object({
|
||||
species: z.string().trim().min(1).max(120),
|
||||
dateOfBirth: dateStringSchema.optional().or(z.literal('')),
|
||||
gotchaDay: dateStringSchema.optional().or(z.literal('')),
|
||||
chartColor: chartColorSchema.optional(),
|
||||
photoDataUrl: photoDataUrlSchema.optional().or(z.literal('')),
|
||||
});
|
||||
|
||||
const weightSchema = z.object({
|
||||
@@ -68,6 +75,8 @@ type BirdRow = {
|
||||
species: string;
|
||||
date_of_birth: string | null;
|
||||
gotcha_day: string | null;
|
||||
chart_color: string;
|
||||
photo_data_url: string | null;
|
||||
created_at: string;
|
||||
latest_weight_grams: string | null;
|
||||
latest_recorded_on: string | null;
|
||||
@@ -111,7 +120,7 @@ app.use(
|
||||
legacyHeaders: false,
|
||||
}),
|
||||
);
|
||||
app.use(express.json({ limit: '300kb' }));
|
||||
app.use(express.json({ limit: '2mb' }));
|
||||
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
|
||||
|
||||
const emptyToNull = (value?: string) => {
|
||||
@@ -126,11 +135,27 @@ const normalizeBird = (row: BirdRow) => ({
|
||||
species: row.species,
|
||||
dateOfBirth: row.date_of_birth,
|
||||
gotchaDay: row.gotcha_day,
|
||||
chartColor: row.chart_color,
|
||||
photoDataUrl: row.photo_data_url,
|
||||
createdAt: row.created_at,
|
||||
latestWeightGrams: row.latest_weight_grams ? Number(row.latest_weight_grams) : null,
|
||||
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) => ({
|
||||
id: row.id,
|
||||
birdId: row.bird_id,
|
||||
@@ -159,12 +184,16 @@ const ensureSchema = async () => {
|
||||
species VARCHAR(120) NOT NULL,
|
||||
date_of_birth DATE,
|
||||
gotcha_day DATE,
|
||||
chart_color VARCHAR(7) NOT NULL DEFAULT '#cb3a35',
|
||||
photo_data_url TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
ALTER TABLE birds
|
||||
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 (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -197,15 +226,7 @@ const ensureSchema = async () => {
|
||||
const getBirdById = async (birdId: string) => {
|
||||
const result = await pool.query<BirdRow>(
|
||||
`SELECT
|
||||
birds.id,
|
||||
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
|
||||
${birdSelectFields}
|
||||
FROM birds
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT weight_grams, recorded_on
|
||||
@@ -229,15 +250,7 @@ app.get('/api/birds', async (_req: Request, res: Response, next: NextFunction) =
|
||||
try {
|
||||
const result = await pool.query<BirdRow>(`
|
||||
SELECT
|
||||
birds.id,
|
||||
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
|
||||
${birdSelectFields}
|
||||
FROM birds
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT weight_grams, recorded_on
|
||||
@@ -265,15 +278,17 @@ app.post('/api/birds', async (req: Request, res: Response, next: NextFunction) =
|
||||
|
||||
try {
|
||||
const result = await pool.query<BirdRow>(
|
||||
`INSERT INTO birds (name, tag_id, species, date_of_birth, gotcha_day)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
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`,
|
||||
`INSERT INTO birds (name, tag_id, species, date_of_birth, gotcha_day, chart_color, photo_data_url)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
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.tagId,
|
||||
parsed.data.species,
|
||||
emptyToNull(parsed.data.dateOfBirth),
|
||||
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) => {
|
||||
try {
|
||||
const result = await pool.query<{ id: string }>('DELETE FROM birds WHERE id = $1 RETURNING id', [req.params.birdId]);
|
||||
|
||||
@@ -5,7 +5,6 @@ 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"]
|
||||
|
||||
+432
-76
@@ -7,6 +7,8 @@ type Bird = {
|
||||
species: string;
|
||||
dateOfBirth: string | null;
|
||||
gotchaDay: string | null;
|
||||
chartColor: string;
|
||||
photoDataUrl: string | null;
|
||||
createdAt: string;
|
||||
latestWeightGrams: number | null;
|
||||
latestRecordedOn: string | null;
|
||||
@@ -29,9 +31,40 @@ type VetVisit = {
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
type BirdFormState = {
|
||||
name: string;
|
||||
tagId: string;
|
||||
species: string;
|
||||
dateOfBirth: string;
|
||||
gotchaDay: string;
|
||||
chartColor: string;
|
||||
photoDataUrl: string;
|
||||
};
|
||||
|
||||
type AppPage = 'overview' | 'flock' | 'settings';
|
||||
|
||||
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) => {
|
||||
if (!value) {
|
||||
@@ -57,6 +90,45 @@ const formatShortDate = (value: string | null) => {
|
||||
};
|
||||
|
||||
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) => {
|
||||
if (!points.length) {
|
||||
@@ -95,30 +167,44 @@ const chartDots = (points: WeightRecord[], width = 520, height = 180) => {
|
||||
}));
|
||||
};
|
||||
|
||||
const birdLineStyles = [
|
||||
{ stroke: '#cb3a35' },
|
||||
{ stroke: '#238a5a' },
|
||||
{ stroke: '#2769b3' },
|
||||
{ stroke: '#f0b63f' },
|
||||
{ stroke: '#2f8f98' },
|
||||
];
|
||||
const buildOverviewSeries = (points: WeightRecord[], minWeight: number, maxWeight: number, startDate: Date, endDate: Date) => {
|
||||
const innerWidth = OVERVIEW_WIDTH - OVERVIEW_PADDING.left - OVERVIEW_PADDING.right;
|
||||
const innerHeight = OVERVIEW_HEIGHT - OVERVIEW_PADDING.top - OVERVIEW_PADDING.bottom;
|
||||
const weightSpread = Math.max(maxWeight - minWeight, 1);
|
||||
const startMs = startDate.getTime();
|
||||
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() {
|
||||
const [activePage, setActivePage] = useState<AppPage>('overview');
|
||||
const [birds, setBirds] = useState<Bird[]>([]);
|
||||
const [selectedBirdId, setSelectedBirdId] = useState<string>('');
|
||||
const [editingBirdId, setEditingBirdId] = 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 [birdForm, setBirdForm] = useState<BirdFormState>(emptyBirdForm);
|
||||
const [birdPhotoName, setBirdPhotoName] = useState('');
|
||||
const [savingBird, setSavingBird] = useState(false);
|
||||
const [weightForm, setWeightForm] = useState({
|
||||
weightGrams: '',
|
||||
recordedOn: new Date().toISOString().slice(0, 10),
|
||||
@@ -143,6 +229,10 @@ function App() {
|
||||
() => birds.find((bird) => bird.id === selectedBirdId) ?? birds[0] ?? null,
|
||||
[birds, selectedBirdId],
|
||||
);
|
||||
const editingBird = useMemo(
|
||||
() => birds.find((bird) => bird.id === editingBirdId) ?? null,
|
||||
[birds, editingBirdId],
|
||||
);
|
||||
|
||||
const totalWeightEntries = useMemo(
|
||||
() => 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.`;
|
||||
}, [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(() => {
|
||||
const loadBirds = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
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 ?? [];
|
||||
|
||||
setBirds(nextBirds);
|
||||
@@ -205,8 +349,13 @@ function App() {
|
||||
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();
|
||||
|
||||
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 ?? []);
|
||||
setVetVisits(visitsData.vetVisits ?? []);
|
||||
@@ -229,7 +378,12 @@ function App() {
|
||||
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();
|
||||
|
||||
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;
|
||||
}),
|
||||
);
|
||||
@@ -243,35 +397,117 @@ function App() {
|
||||
void loadAllBirdWeights();
|
||||
}, [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>) => {
|
||||
event.preventDefault();
|
||||
setError('');
|
||||
setSavingBird(true);
|
||||
|
||||
const isEditing = Boolean(editingBirdId);
|
||||
const endpoint = isEditing ? `${apiBaseUrl}/birds/${editingBirdId}` : `${apiBaseUrl}/birds`;
|
||||
const method = isEditing ? 'PUT' : 'POST';
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/birds`, {
|
||||
method: 'POST',
|
||||
const response = await fetch(endpoint, {
|
||||
method,
|
||||
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.');
|
||||
throw new Error(await readErrorMessage(response, `Unable to ${isEditing ? 'update' : '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: '',
|
||||
const data = await readJsonSafely<{ bird: Bird }>(response);
|
||||
if (!data?.bird) {
|
||||
throw new Error(`Unable to ${isEditing ? 'update' : 'create'} flock member.`);
|
||||
}
|
||||
const savedBird = data.bird as Bird;
|
||||
|
||||
setBirds((current) => {
|
||||
if (isEditing) {
|
||||
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) {
|
||||
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) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error ?? 'Unable to save weight.');
|
||||
throw new Error(await readErrorMessage(response, '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));
|
||||
|
||||
setWeights(nextWeights);
|
||||
@@ -346,11 +584,13 @@ function App() {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error ?? 'Unable to save vet visit.');
|
||||
throw new Error(await readErrorMessage(response, '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) =>
|
||||
[data.vetVisit, ...current].sort((left, right) => right.visitedOn.localeCompare(left.visitedOn)),
|
||||
);
|
||||
@@ -387,8 +627,7 @@ function App() {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error ?? 'Unable to remove flock member.');
|
||||
throw new Error(await readErrorMessage(response, 'Unable to remove flock member.'));
|
||||
}
|
||||
|
||||
const nextBirds = birds.filter((bird) => bird.id !== selectedBird.id);
|
||||
@@ -401,6 +640,12 @@ function App() {
|
||||
setSelectedBirdId(nextBirds[0]?.id ?? '');
|
||||
setWeights([]);
|
||||
setVetVisits([]);
|
||||
|
||||
if (editingBirdId === selectedBird.id) {
|
||||
setEditingBirdId('');
|
||||
setBirdForm(emptyBirdForm);
|
||||
setBirdPhotoName('');
|
||||
}
|
||||
} catch (removeError) {
|
||||
setError(removeError instanceof Error ? removeError.message : 'Unable to remove flock member.');
|
||||
} finally {
|
||||
@@ -435,10 +680,11 @@ function App() {
|
||||
|
||||
return (
|
||||
<main className="app-shell">
|
||||
<section className="hero-card">
|
||||
<aside className="side-nav panel">
|
||||
<div>
|
||||
<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">
|
||||
<button className={`page-tab ${activePage === 'overview' ? 'active' : ''}`} onClick={() => setActivePage('overview')} type="button">
|
||||
Overview
|
||||
@@ -450,6 +696,13 @@ function App() {
|
||||
Settings
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="content-shell">
|
||||
<section className="hero-card">
|
||||
<div>
|
||||
<p className="eyebrow">Dashboard</p>
|
||||
<h1>FlockPal dashboard</h1>
|
||||
</div>
|
||||
<div className="hero-stats">
|
||||
<article>
|
||||
@@ -482,42 +735,54 @@ function App() {
|
||||
|
||||
<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 (
|
||||
{overviewChart.yTicks.map((tick) => (
|
||||
<g key={tick.label}>
|
||||
<line
|
||||
x1={OVERVIEW_PADDING.left}
|
||||
y1={tick.y}
|
||||
x2={OVERVIEW_WIDTH - OVERVIEW_PADDING.right}
|
||||
y2={tick.y}
|
||||
className="chart-grid-line"
|
||||
/>
|
||||
<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}>
|
||||
<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>
|
||||
{points.length > 1 ? (
|
||||
<path d={toOverviewPath(points)} fill="none" stroke={bird.chartColor} strokeWidth="3.5" strokeLinecap="round" />
|
||||
) : null}
|
||||
{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>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="legend-grid">
|
||||
{birds.map((bird, index) => {
|
||||
const style = birdLineStyles[index % birdLineStyles.length];
|
||||
const birdWeights = allBirdWeights[bird.id] ?? [];
|
||||
|
||||
{overviewChart.plottedBirds.map(({ bird }) => {
|
||||
return (
|
||||
<article key={bird.id} className="legend-card">
|
||||
<span className="legend-swatch" style={{ background: style.stroke }} />
|
||||
<span className="legend-swatch" style={{ background: bird.chartColor }} />
|
||||
<div>
|
||||
<strong>{bird.name}</strong>
|
||||
<small>
|
||||
{bird.species} • {birdWeights.length ? `${birdWeights.length} entries` : 'No entries yet'}
|
||||
</small>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
@@ -575,7 +840,6 @@ function App() {
|
||||
) : null}
|
||||
|
||||
{activePage === 'flock' ? (
|
||||
<>
|
||||
<section className="dashboard-grid">
|
||||
<aside className="panel bird-list-panel">
|
||||
<div className="panel-header">
|
||||
@@ -583,6 +847,9 @@ function App() {
|
||||
<p className="eyebrow">Flock</p>
|
||||
<h2>Flock members</h2>
|
||||
</div>
|
||||
<button className="secondary-button" onClick={startCreateBird} type="button">
|
||||
Add bird
|
||||
</button>
|
||||
</div>
|
||||
<div className="bird-list">
|
||||
{birds.map((bird) => (
|
||||
@@ -592,10 +859,21 @@ function App() {
|
||||
onClick={() => setSelectedBirdId(bird.id)}
|
||||
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>
|
||||
<small>
|
||||
{bird.species} • {bird.tagId}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<strong>{formatWeight(bird.latestWeightGrams)}</strong>
|
||||
</button>
|
||||
))}
|
||||
@@ -609,14 +887,37 @@ function App() {
|
||||
<h2>{selectedBird ? selectedBird.name : 'Choose a flock member'}</h2>
|
||||
</div>
|
||||
{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}>
|
||||
{deletingBird ? 'Removing...' : 'Remove from flock'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{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">
|
||||
<article className="detail-card">
|
||||
<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">
|
||||
<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" />
|
||||
<stop offset="0%" stopColor={selectedBird.chartColor} stopOpacity="0.45" />
|
||||
<stop offset="100%" stopColor={selectedBird.chartColor} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={chartPath(weights)} fill="none" stroke="url(#lineGlow)" strokeWidth="4" strokeLinecap="round" />
|
||||
@@ -781,7 +1080,6 @@ function App() {
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{activePage === 'settings' ? (
|
||||
@@ -790,9 +1088,33 @@ function App() {
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<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 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}>
|
||||
<label>
|
||||
Bird name
|
||||
@@ -822,8 +1144,41 @@ function App() {
|
||||
onChange={(event) => setBirdForm({ ...birdForm, gotchaDay: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<button className="primary-button" type="submit">
|
||||
Save flock member
|
||||
<label>
|
||||
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>
|
||||
</form>
|
||||
</article>
|
||||
@@ -872,6 +1227,7 @@ function App() {
|
||||
</article>
|
||||
</section>
|
||||
) : null}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
+204
-45
@@ -11,10 +11,12 @@
|
||||
--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%);
|
||||
radial-gradient(circle at 14% 10%, rgba(222, 124, 58, 0.28), transparent 22%),
|
||||
radial-gradient(circle at 82% 12%, rgba(53, 136, 110, 0.26), transparent 20%),
|
||||
radial-gradient(circle at 24% 84%, rgba(221, 179, 78, 0.2), transparent 22%),
|
||||
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;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
@@ -34,11 +36,27 @@ body,
|
||||
body {
|
||||
min-height: 100vh;
|
||||
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,
|
||||
input,
|
||||
textarea {
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
@@ -51,7 +69,8 @@ button:disabled {
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
margin-top: 0.5rem;
|
||||
border: 1px solid rgba(39, 105, 179, 0.16);
|
||||
@@ -62,7 +81,8 @@ textarea {
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus {
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: rgba(39, 105, 179, 0.62);
|
||||
box-shadow: 0 0 0 4px rgba(39, 105, 179, 0.14);
|
||||
@@ -73,11 +93,25 @@ textarea {
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
max-width: 1280px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
display: grid;
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
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 {
|
||||
@@ -107,22 +141,6 @@ textarea {
|
||||
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;
|
||||
@@ -135,21 +153,18 @@ textarea {
|
||||
}
|
||||
|
||||
.page-tabs {
|
||||
display: flex;
|
||||
display: grid;
|
||||
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;
|
||||
border-radius: 18px;
|
||||
padding: 0.95rem 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;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.page-tab.active {
|
||||
@@ -265,11 +280,49 @@ textarea {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bird-list {
|
||||
display: grid;
|
||||
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 {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
@@ -311,17 +364,8 @@ textarea {
|
||||
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::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.overview-chart-card {
|
||||
@@ -334,6 +378,22 @@ textarea {
|
||||
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,
|
||||
.recent-list,
|
||||
.detail-grid,
|
||||
@@ -361,6 +421,27 @@ textarea {
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
|
||||
.large-swatch {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.detail-card span,
|
||||
.summary-card span {
|
||||
color: var(--muted);
|
||||
@@ -443,6 +529,19 @@ label {
|
||||
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 {
|
||||
border: 1px solid rgba(171, 44, 44, 0.18);
|
||||
border-radius: 18px;
|
||||
@@ -470,13 +569,73 @@ label {
|
||||
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) {
|
||||
.app-shell,
|
||||
.hero-card,
|
||||
.dashboard-grid,
|
||||
.forms-grid,
|
||||
.hero-stats,
|
||||
.chart-footer,
|
||||
.inline-form {
|
||||
.inline-form,
|
||||
.profile-hero,
|
||||
.photo-editor {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -484,7 +643,7 @@ label {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.page-tabs {
|
||||
margin-top: 1rem;
|
||||
.side-nav {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user