visual improvements

This commit is contained in:
Corey Blais
2026-04-07 18:23:28 -04:00
parent 68ab8b12d2
commit 2aff57ee7f
5 changed files with 961 additions and 370 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
"private": true,
"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
View File
@@ -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]);
-1
View File
@@ -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"]
+656 -300
View File
File diff suppressed because it is too large Load Diff
+204 -45
View File
@@ -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;
}
}