From a257017ce9a094656332bec2caa24af08dfc32f3 Mon Sep 17 00:00:00 2001 From: CalebLovell Date: Wed, 22 Jul 2026 17:58:08 -0500 Subject: [PATCH 1/4] Polish web-ui visual design: flatter background, tighter corners Remove the hill/tree/sun hero graphic and the dotted paper-grain background texture per feedback -- flat solid background everywhere. Also reduce the shared --radius token so cards, buttons, inputs, and badges read with tighter, less rounded corners site-wide, and add scrollbar-gutter: stable so pages that change height (e.g. filtering a grid) don't shift horizontally when the scrollbar appears/disappears. Co-Authored-By: Claude Sonnet 5 --- web-ui/src/components/HeroBand.tsx | 33 ------------------------------ web-ui/src/routes/detections.tsx | 2 +- web-ui/src/routes/index.tsx | 6 ++---- web-ui/src/styles.css | 21 ++++++++----------- 4 files changed, 11 insertions(+), 51 deletions(-) delete mode 100644 web-ui/src/components/HeroBand.tsx diff --git a/web-ui/src/components/HeroBand.tsx b/web-ui/src/components/HeroBand.tsx deleted file mode 100644 index 65fbe494..00000000 --- a/web-ui/src/components/HeroBand.tsx +++ /dev/null @@ -1,33 +0,0 @@ -// A quiet nod to the field-guide illustrations this app's data comes -// from -- rolling hills, a treeline, one sun. Used once, on the overview -// page, so it reads as a signature rather than decoration. -export function HeroBand() { - return ( - - ); -} diff --git a/web-ui/src/routes/detections.tsx b/web-ui/src/routes/detections.tsx index 37aeb93a..cf16de4b 100644 --- a/web-ui/src/routes/detections.tsx +++ b/web-ui/src/routes/detections.tsx @@ -25,7 +25,7 @@ function Detections() { The last {detections.length} detections, most recent first.

-
+
diff --git a/web-ui/src/routes/index.tsx b/web-ui/src/routes/index.tsx index da4595a8..7be0171b 100644 --- a/web-ui/src/routes/index.tsx +++ b/web-ui/src/routes/index.tsx @@ -1,6 +1,5 @@ import { createFileRoute } from "@tanstack/react-router"; -import { HeroBand } from "#/components/HeroBand.tsx"; import { Card, CardContent, @@ -33,8 +32,7 @@ function Home() { return (
- -

+

What's singing right now

@@ -52,7 +50,7 @@ function Home() {

Recent detections

-
+
diff --git a/web-ui/src/styles.css b/web-ui/src/styles.css index 63a65605..d6d532d5 100644 --- a/web-ui/src/styles.css +++ b/web-ui/src/styles.css @@ -46,7 +46,7 @@ --chart-3: var(--sage); --chart-4: var(--clay); --chart-5: var(--ink); - --radius: 0.625rem; + --radius: 0.375rem; --sidebar: var(--paper-raised); --sidebar-foreground: var(--ink); --sidebar-primary: var(--moss); @@ -98,6 +98,13 @@ --color-sidebar-ring: var(--sidebar-ring); } +html { + /* Reserves the scrollbar's width always, so content that changes height + (e.g. filtering a grid) never shifts horizontally when the scrollbar + appears or disappears. */ + scrollbar-gutter: stable; +} + html, body, #app { @@ -114,18 +121,6 @@ body { -moz-osx-font-smoothing: grayscale; } -/* A faint paper grain -- texture, not glow. */ -body::before { - content: ''; - position: fixed; - inset: 0; - pointer-events: none; - z-index: -1; - opacity: 0.5; - background-image: radial-gradient(color-mix(in oklab, var(--moss) 35%, transparent) 0.6px, transparent 0.6px); - background-size: 3px 3px; -} - a { color: var(--moss); text-decoration-color: color-mix(in oklab, var(--moss) 45%, transparent); From 79efe59da6dd092d4f55b6c7fe84afb425f4bbe3 Mon Sep 17 00:00:00 2001 From: CalebLovell Date: Wed, 22 Jul 2026 17:58:30 -0500 Subject: [PATCH 2/4] Add Species page: card grid with search, sort, pagination, and real audio playback Rebuilds the species listing as a card grid (search box; sort by most recordings / recent / A-Z with icons and pagination), each card showing a Wikipedia thumbnail (with graceful fallback), this-hour/all-time counts, and Play/Wiki/eBird actions. Audio playback is fully wired, not a placeholder: adds BIRDNET_EXTRACTED_DIR config and a dynamic server route that streams clips from BirdNET-Pi's real extraction path (BirdSongs/Extracted/ By_Date/..., a sibling of the repo, matching production layout). Wikipedia images come from their public summary API (image + canonical page URL, no key needed); eBird links go through a scoped search since their species pages require login for anonymous requests. Worked around three real bugs in this bleeding-edge TanStack Start/ Nitro version along the way: a Node-only import leaking into the client bundle (fixed by marking db/index.ts server-only and splitting audio.ts's filesystem code into audio.server.ts), a 3-segment dynamic route silently failing to register (collapsed to 2 segments), and native
- - - Species - Scientific name - Last detected - Detections - - - - {species.length === 0 ? ( - - - No species detected yet. - - +
+ { + if (!value) return; + setSort(value as SortKey); + setPage(1); + }} + > + + + Most + + + + Recent + + + + A–Z + + + { + setSearch(e.target.value); + setPage(1); + }} + className="sm:max-w-xs" + /> +
+ + {pageItems.length === 0 ? ( +

+ No species match “{search}”. +

+ ) : ( +
+ {pageItems.map((card) => ( + + ))} +
+ )} + + {pageCount > 1 && ( + + + + { + e.preventDefault(); + setPage((p) => Math.max(1, p - 1)); + }} + className={ + currentPage === 1 ? "pointer-events-none opacity-50" : "" + } + /> + + {Array.from({ length: pageCount }, (_, i) => i + 1).map((p) => ( + + { + e.preventDefault(); + setPage(p); + }} + > + {p} + + + ))} + + { + e.preventDefault(); + setPage((p) => Math.min(pageCount, p + 1)); + }} + className={ + currentPage === pageCount + ? "pointer-events-none opacity-50" + : "" + } + /> + + + + )} + + ); +} + +function SpeciesCard({ card }: { card: LifeListCard }) { + const audioRef = useRef(null); + const objectUrlRef = useRef(null); + const [isPlaying, setIsPlaying] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + return () => { + if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current); + }; + }, []); + + async function togglePlay() { + const audio = audioRef.current; + if (!audio || !card.audioUrl) return; + + if (isPlaying) { + audio.pause(); + return; + } + + // The browser's native
+ {isPlaying ? "Pause" : "Play"} + + + + {card.audioUrl && ( + + )} +
); From a468b4ee7a3eebcaa42aa52591e61fa9aeec5a89 Mon Sep 17 00:00:00 2001 From: CalebLovell Date: Wed, 22 Jul 2026 17:58:48 -0500 Subject: [PATCH 3/4] Generate placeholder audio clips in the seed script For each species, synthesizes a short distinct tone at its most recent detection's extraction path (BirdSongs/Extracted/By_Date/...), matching the same BIRDNET_EXTRACTED_DIR default web-ui uses, so the new play-button feature has real audio to play during local dev. Co-Authored-By: Claude Sonnet 5 --- scripts/seed_test_data.py | 86 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 3 deletions(-) diff --git a/scripts/seed_test_data.py b/scripts/seed_test_data.py index 57384dbd..3618e260 100644 --- a/scripts/seed_test_data.py +++ b/scripts/seed_test_data.py @@ -6,19 +6,34 @@ the last hour so "today"/"last hour" stats aren't empty. Matches the exact schema scripts/createdb.sh creates. +Also generates one placeholder audio clip per species (for its most recent +detection) at BirdNET-Pi's real extraction path, so web-ui's play button +has something real to play locally -- the same BIRDNET_EXTRACTED_DIR +default web-ui itself uses. + Usage: - python3 scripts/seed_test_data.py # wipes and reseeds - python3 scripts/seed_test_data.py --append # adds on top of existing rows - python3 scripts/seed_test_data.py --days 14 # shorter history + python3 scripts/seed_test_data.py # wipes and reseeds + python3 scripts/seed_test_data.py --append # adds on top of existing rows + python3 scripts/seed_test_data.py --days 14 # shorter history + python3 scripts/seed_test_data.py --no-audio # skip placeholder audio clips """ import argparse +import math import os import random import sqlite3 +import struct +import wave from datetime import datetime, timedelta DB_PATH = os.path.join(os.path.dirname(__file__), 'birds.db') +# Mirrors web-ui's default BIRDNET_EXTRACTED_DIR: BirdSongs lives as a +# sibling of the BirdNET-Pi checkout, never inside the repo itself. +DEFAULT_EXTRACTED_DIR = os.path.normpath( + os.path.join(os.path.dirname(__file__), '..', '..', 'BirdSongs', 'Extracted') +) + # (common_name, scientific_name, relative frequency weight) REGULAR_SPECIES = [ ('Northern Cardinal', 'Cardinalis cardinalis', 10), @@ -154,12 +169,74 @@ def generate_rows(days: int): return rows +def write_placeholder_wav(path: str, seed_text: str, duration: float = 1.2, framerate: int = 22050): + """Writes a short synthesized tone, distinct per species, so the + web-ui's play button has something real to play during local dev.""" + freq = 350 + (abs(hash(seed_text)) % 700) + frame_count = int(duration * framerate) + os.makedirs(os.path.dirname(path), exist_ok=True) + with wave.open(path, 'w') as wav_file: + wav_file.setnchannels(1) + wav_file.setsampwidth(2) + wav_file.setframerate(framerate) + frames = bytearray() + for i in range(frame_count): + t = i / framerate + envelope = max(0.0, min(1.0, t * 8, (duration - t) * 8)) + sample = int(32767 * 0.3 * envelope * math.sin(2 * math.pi * freq * t)) + frames += struct.pack('/ + /), and repoints that one row's File_Name at the + matching .wav so the DB and the file on disk agree.""" + cur = con.cursor() + cur.execute(""" + SELECT Com_Name, Date, Time, File_Name FROM detections + ORDER BY Date DESC, Time DESC + """) + seen = set() + updates = [] + for com_name, date, time, file_name in cur.fetchall(): + if com_name in seen: + continue + seen.add(com_name) + + com_name_safe = com_name.replace("'", '').replace(' ', '_') + # ':' is valid in filenames on the Pi's Linux filesystem (where the + # real format comes from) but illegal on Windows dev machines, so + # the placeholder file itself uses a filesystem-safe name. + stem = os.path.splitext(file_name)[0].replace(':', '-') + wav_name = f'{stem}.wav' + full_path = os.path.join(extracted_dir, 'By_Date', date, com_name_safe, wav_name) + write_placeholder_wav(full_path, com_name) + updates.append((wav_name, com_name, date, time)) + + cur.executemany( + 'UPDATE detections SET File_Name = ? WHERE Com_Name = ? AND Date = ? AND Time = ?', + updates, + ) + con.commit() + print(f'Generated {len(updates)} placeholder audio clips under {extracted_dir}') + + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--db', default=DB_PATH, help='Path to birds.db') parser.add_argument('--days', type=int, default=28, help='Number of days of history to generate, including today') parser.add_argument('--append', action='store_true', help="Don't clear existing rows first") parser.add_argument('--seed', type=int, default=None, help='Random seed, for reproducible output') + parser.add_argument( + '--extracted-dir', + default=os.environ.get('BIRDNET_EXTRACTED_DIR', DEFAULT_EXTRACTED_DIR), + help='Directory to write placeholder audio clips into', + ) + parser.add_argument( + '--no-audio', action='store_true', help='Skip generating placeholder audio clips' + ) args = parser.parse_args() if args.seed is not None: @@ -193,6 +270,9 @@ def main(): ) con.commit() + if not args.no_audio: + seed_placeholder_audio(con, args.extracted_dir) + total = cur.execute('SELECT COUNT(*) FROM detections').fetchone()[0] species = cur.execute('SELECT COUNT(DISTINCT Com_Name) FROM detections').fetchone()[0] con.close() From e68b99f3313201d2fad72090482dc1b01d6d333d Mon Sep 17 00:00:00 2001 From: CalebLovell Date: Wed, 22 Jul 2026 18:20:22 -0500 Subject: [PATCH 4/4] Move Species page search/sort/page state into the URL Retrofits the Species page's search box, sort toggle, and pagination from component state (useState) to the route's search params, using the same Zod-validated pattern as the Stats page (.default().catch() for a required-output/optional-input schema, stripSearchParams to keep the URL clean at defaults). Filtering is now shareable, bookmarkable, and survives back/forward navigation. Co-Authored-By: Claude Sonnet 5 --- web-ui/package-lock.json | 3 +- web-ui/package.json | 3 +- web-ui/src/routes/species.tsx | 60 +++++++++++++++++++++++++++-------- 3 files changed, 51 insertions(+), 15 deletions(-) diff --git a/web-ui/package-lock.json b/web-ui/package-lock.json index 98a2a19b..e2c3d7d9 100644 --- a/web-ui/package-lock.json +++ b/web-ui/package-lock.json @@ -22,7 +22,8 @@ "react-dom": "^19.2.0", "tailwind-merge": "^3.0.2", "tailwindcss": "^4.1.18", - "tw-animate-css": "^1.3.6" + "tw-animate-css": "^1.3.6", + "zod": "^4.4.3" }, "devDependencies": { "@biomejs/biome": "2.4.5", diff --git a/web-ui/package.json b/web-ui/package.json index 2b9a2506..53e6d867 100644 --- a/web-ui/package.json +++ b/web-ui/package.json @@ -36,7 +36,8 @@ "react-dom": "^19.2.0", "tailwind-merge": "^3.0.2", "tailwindcss": "^4.1.18", - "tw-animate-css": "^1.3.6" + "tw-animate-css": "^1.3.6", + "zod": "^4.4.3" }, "devDependencies": { "@biomejs/biome": "2.4.5", diff --git a/web-ui/src/routes/species.tsx b/web-ui/src/routes/species.tsx index 3cfd3b75..1da6f52c 100644 --- a/web-ui/src/routes/species.tsx +++ b/web-ui/src/routes/species.tsx @@ -1,4 +1,4 @@ -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, stripSearchParams } from "@tanstack/react-router"; import { ArrowDownAZ, Binoculars, @@ -11,6 +11,7 @@ import { Play, } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; +import { z } from "zod"; import { Button } from "#/components/ui/button.tsx"; import { Input } from "#/components/ui/input.tsx"; @@ -25,20 +26,42 @@ import { import { ToggleGroup, ToggleGroupItem } from "#/components/ui/toggle-group.tsx"; import { getLifeListCards, type LifeListCard } from "#/lib/detections.ts"; +const SORT_KEYS = ["count", "recent", "alpha"] as const; +type SortKey = (typeof SORT_KEYS)[number]; + +const DEFAULT_SEARCH = { q: "", sort: "count" as SortKey, page: 1 }; + +// Search/sort/page all live in the URL (not component state), so filtering +// is shareable/bookmarkable and survives back/forward navigation. +const speciesSearchSchema = z.object({ + q: z.string().default(DEFAULT_SEARCH.q).catch(DEFAULT_SEARCH.q), + sort: z + .enum(SORT_KEYS) + .default(DEFAULT_SEARCH.sort) + .catch(DEFAULT_SEARCH.sort), + page: z + .number() + .int() + .min(1) + .default(DEFAULT_SEARCH.page) + .catch(DEFAULT_SEARCH.page), +}); + export const Route = createFileRoute("/species")({ + validateSearch: speciesSearchSchema, + search: { + middlewares: [stripSearchParams(DEFAULT_SEARCH)], + }, component: Species, loader: () => getLifeListCards(), }); -type SortKey = "count" | "recent" | "alpha"; - const PAGE_SIZE = 24; function Species() { const cards = Route.useLoaderData(); - const [search, setSearch] = useState(""); - const [sort, setSort] = useState("count"); - const [page, setPage] = useState(1); + const { q: search, sort, page } = Route.useSearch(); + const navigate = Route.useNavigate(); const filtered = useMemo(() => { const query = search.trim().toLowerCase(); @@ -79,8 +102,9 @@ function Species() { value={sort} onValueChange={(value) => { if (!value) return; - setSort(value as SortKey); - setPage(1); + navigate({ + search: (prev) => ({ ...prev, sort: value as SortKey, page: 1 }), + }); }} > @@ -100,8 +124,8 @@ function Species() { placeholder="Search species..." value={search} onChange={(e) => { - setSearch(e.target.value); - setPage(1); + const value = e.target.value; + navigate({ search: (prev) => ({ ...prev, q: value, page: 1 }) }); }} className="sm:max-w-xs" /> @@ -127,7 +151,12 @@ function Species() { href="#" onClick={(e) => { e.preventDefault(); - setPage((p) => Math.max(1, p - 1)); + navigate({ + search: (prev) => ({ + ...prev, + page: Math.max(1, currentPage - 1), + }), + }); }} className={ currentPage === 1 ? "pointer-events-none opacity-50" : "" @@ -141,7 +170,7 @@ function Species() { isActive={p === currentPage} onClick={(e) => { e.preventDefault(); - setPage(p); + navigate({ search: (prev) => ({ ...prev, page: p }) }); }} > {p} @@ -153,7 +182,12 @@ function Species() { href="#" onClick={(e) => { e.preventDefault(); - setPage((p) => Math.min(pageCount, p + 1)); + navigate({ + search: (prev) => ({ + ...prev, + page: Math.min(pageCount, currentPage + 1), + }), + }); }} className={ currentPage === pageCount