visual improvements
This commit is contained in:
@@ -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
@@ -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]);
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user