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 (
-
-
-
-
- {[64, 128, 190, 246, 300].map((x, i) => (
-
- ))}
-
- );
-}
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 requests 404ing while identical fetch() calls
succeeded (worked around by fetching the clip and playing it from a
Blob URL). Verified in both dev and a production build.
Card layout also fixes two bugs found after building it: the image
wrapper's height varied across cards despite aspect-square (a flex/
aspect-ratio interaction quirk) -- replaced with an explicit fixed
height. Reordered content to name/sciname, then image, then stats,
then actions, top to bottom.
Co-Authored-By: Claude Sonnet 5
---
web-ui/src/components/ui/input.tsx | 21 ++
web-ui/src/components/ui/pagination.tsx | 125 +++++++
web-ui/src/components/ui/toggle-group.tsx | 82 +++++
web-ui/src/components/ui/toggle.tsx | 45 +++
web-ui/src/db/index.ts | 2 +
web-ui/src/lib/audio.server.ts | 42 +++
web-ui/src/lib/audio.ts | 49 +++
web-ui/src/lib/detections.ts | 74 +++-
web-ui/src/lib/wikipedia.ts | 53 +++
web-ui/src/routeTree.gen.ts | 31 +-
.../routes/api/audio/$date/$speciesAndFile.ts | 31 ++
web-ui/src/routes/species.tsx | 331 +++++++++++++++---
12 files changed, 828 insertions(+), 58 deletions(-)
create mode 100644 web-ui/src/components/ui/input.tsx
create mode 100644 web-ui/src/components/ui/pagination.tsx
create mode 100644 web-ui/src/components/ui/toggle-group.tsx
create mode 100644 web-ui/src/components/ui/toggle.tsx
create mode 100644 web-ui/src/lib/audio.server.ts
create mode 100644 web-ui/src/lib/audio.ts
create mode 100644 web-ui/src/lib/wikipedia.ts
create mode 100644 web-ui/src/routes/api/audio/$date/$speciesAndFile.ts
diff --git a/web-ui/src/components/ui/input.tsx b/web-ui/src/components/ui/input.tsx
new file mode 100644
index 00000000..89e8a56c
--- /dev/null
+++ b/web-ui/src/components/ui/input.tsx
@@ -0,0 +1,21 @@
+import type * as React from "react";
+
+import { cn } from "#/lib/utils.ts";
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ );
+}
+
+export { Input };
diff --git a/web-ui/src/components/ui/pagination.tsx b/web-ui/src/components/ui/pagination.tsx
new file mode 100644
index 00000000..1ab6066f
--- /dev/null
+++ b/web-ui/src/components/ui/pagination.tsx
@@ -0,0 +1,125 @@
+import {
+ ChevronLeftIcon,
+ ChevronRightIcon,
+ MoreHorizontalIcon,
+} from "lucide-react";
+import type * as React from "react";
+import { type Button, buttonVariants } from "#/components/ui/button.tsx";
+import { cn } from "#/lib/utils.ts";
+
+function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
+ return (
+
+ );
+}
+
+function PaginationContent({
+ className,
+ ...props
+}: React.ComponentProps<"ul">) {
+ return (
+
+ );
+}
+
+function PaginationItem({ ...props }: React.ComponentProps<"li">) {
+ return ;
+}
+
+type PaginationLinkProps = {
+ isActive?: boolean;
+} & Pick, "size"> &
+ React.ComponentProps<"a">;
+
+function PaginationLink({
+ className,
+ isActive,
+ size = "icon",
+ ...props
+}: PaginationLinkProps) {
+ return (
+
+ );
+}
+
+function PaginationPrevious({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+ Previous
+
+ );
+}
+
+function PaginationNext({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ Next
+
+
+ );
+}
+
+function PaginationEllipsis({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+
+ More pages
+
+ );
+}
+
+export {
+ Pagination,
+ PaginationContent,
+ PaginationLink,
+ PaginationItem,
+ PaginationPrevious,
+ PaginationNext,
+ PaginationEllipsis,
+};
diff --git a/web-ui/src/components/ui/toggle-group.tsx b/web-ui/src/components/ui/toggle-group.tsx
new file mode 100644
index 00000000..7cb749db
--- /dev/null
+++ b/web-ui/src/components/ui/toggle-group.tsx
@@ -0,0 +1,82 @@
+"use client";
+
+import type { VariantProps } from "class-variance-authority";
+import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui";
+import * as React from "react";
+import { toggleVariants } from "#/components/ui/toggle.tsx";
+import { cn } from "#/lib/utils.ts";
+
+const ToggleGroupContext = React.createContext<
+ VariantProps & {
+ spacing?: number;
+ }
+>({
+ size: "default",
+ variant: "default",
+ spacing: 0,
+});
+
+function ToggleGroup({
+ className,
+ variant,
+ size,
+ spacing = 0,
+ children,
+ ...props
+}: React.ComponentProps &
+ VariantProps & {
+ spacing?: number;
+ }) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+function ToggleGroupItem({
+ className,
+ children,
+ variant,
+ size,
+ ...props
+}: React.ComponentProps &
+ VariantProps) {
+ const context = React.useContext(ToggleGroupContext);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export { ToggleGroup, ToggleGroupItem };
diff --git a/web-ui/src/components/ui/toggle.tsx b/web-ui/src/components/ui/toggle.tsx
new file mode 100644
index 00000000..93d5aa21
--- /dev/null
+++ b/web-ui/src/components/ui/toggle.tsx
@@ -0,0 +1,45 @@
+import { cva, type VariantProps } from "class-variance-authority";
+import { Toggle as TogglePrimitive } from "radix-ui";
+import type * as React from "react";
+
+import { cn } from "#/lib/utils.ts";
+
+const toggleVariants = cva(
+ "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ {
+ variants: {
+ variant: {
+ default: "bg-transparent",
+ outline:
+ "border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
+ },
+ size: {
+ default: "h-9 min-w-9 px-2",
+ sm: "h-8 min-w-8 px-1.5",
+ lg: "h-10 min-w-10 px-2.5",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+function Toggle({
+ className,
+ variant,
+ size,
+ ...props
+}: React.ComponentProps &
+ VariantProps) {
+ return (
+
+ );
+}
+
+export { Toggle, toggleVariants };
diff --git a/web-ui/src/db/index.ts b/web-ui/src/db/index.ts
index a73eb3eb..3f5f5cbc 100644
--- a/web-ui/src/db/index.ts
+++ b/web-ui/src/db/index.ts
@@ -1,3 +1,5 @@
+import "@tanstack/react-start/server-only";
+
import { DatabaseSync } from "node:sqlite";
import { drizzle } from "drizzle-orm/sqlite-proxy";
diff --git a/web-ui/src/lib/audio.server.ts b/web-ui/src/lib/audio.server.ts
new file mode 100644
index 00000000..761d048d
--- /dev/null
+++ b/web-ui/src/lib/audio.server.ts
@@ -0,0 +1,42 @@
+import "@tanstack/react-start/server-only";
+
+import path from "node:path";
+
+// Matches BirdNET-Pi's RECS_DIR/EXTRACTED layout: BirdSongs lives as a
+// sibling of the BirdNET-Pi checkout (web-ui/../../BirdSongs/Extracted),
+// never inside the git repo itself. Point BIRDNET_EXTRACTED_DIR at the
+// real path in production. Read lazily (not at module scope) so this
+// works correctly under edge runtimes that inject env per-request too.
+function extractedDir(): string {
+ return path.resolve(
+ process.env.BIRDNET_EXTRACTED_DIR ?? "../../BirdSongs/Extracted",
+ );
+}
+
+const EXTENSION_MIME: Record = {
+ ".mp3": "audio/mpeg",
+ ".wav": "audio/wav",
+ ".flac": "audio/flac",
+ ".ogg": "audio/ogg",
+};
+
+/**
+ * Resolves a relative "By_Date///" path against the
+ * extracted-clips root, refusing anything that would escape it (path
+ * traversal via `..` segments).
+ */
+export function resolveExtractedFile(relativePath: string): string | null {
+ const root = extractedDir();
+ const resolved = path.resolve(root, relativePath);
+ if (resolved !== root && !resolved.startsWith(root + path.sep)) {
+ return null;
+ }
+ return resolved;
+}
+
+export function mimeTypeFor(filePath: string): string {
+ return (
+ EXTENSION_MIME[path.extname(filePath).toLowerCase()] ??
+ "application/octet-stream"
+ );
+}
diff --git a/web-ui/src/lib/audio.ts b/web-ui/src/lib/audio.ts
new file mode 100644
index 00000000..2501a97d
--- /dev/null
+++ b/web-ui/src/lib/audio.ts
@@ -0,0 +1,49 @@
+// Pure, isomorphic string helpers -- safe to import from client-rendered
+// components. Filesystem-touching code (EXTRACTED_DIR, resolveExtractedFile,
+// mimeTypeFor) lives in audio.server.ts, which is import-protected so it can
+// never end up in the client bundle.
+
+export function commonNameSafe(commonName: string): string {
+ return commonName.replaceAll("'", "").replaceAll(" ", "_");
+}
+
+// The server route only supports 2 chained dynamic segments (3+ breaks route
+// registration in the installed TanStack Start version), so species and
+// filename travel as one segment. A literal "." anywhere in a path segment
+// (e.g. the ".wav" extension) makes Nitro's dev server treat the request as
+// a static-asset lookup before it ever reaches a dynamic route -- and
+// percent-encoding it doesn't survive, since browsers normalize %2E back to
+// "." before sending the request. Base64url has no dots at all, so it
+// sidesteps the problem entirely instead of fighting URL normalization.
+const SEGMENT_SEPARATOR = "::";
+
+function base64UrlEncode(value: string): string {
+ return btoa(value)
+ .replaceAll("+", "-")
+ .replaceAll("/", "_")
+ .replaceAll("=", "");
+}
+
+function base64UrlDecode(value: string): string {
+ const padded = value.replaceAll("-", "+").replaceAll("_", "/");
+ const padding =
+ padded.length % 4 === 0 ? "" : "=".repeat(4 - (padded.length % 4));
+ return atob(padded + padding);
+}
+
+/** Builds the /api/audio URL for a given detection's extracted clip. */
+export function audioUrlFor(
+ date: string,
+ commonName: string,
+ fileName: string,
+): string {
+ const speciesAndFile = `${commonNameSafe(commonName)}${SEGMENT_SEPARATOR}${fileName}`;
+ return `/api/audio/${encodeURIComponent(date)}/${base64UrlEncode(speciesAndFile)}`;
+}
+
+export function splitSpeciesAndFile(
+ encoded: string,
+): [species: string, file: string] {
+ const [species, file] = base64UrlDecode(encoded).split(SEGMENT_SEPARATOR);
+ return [species ?? "", file ?? ""];
+}
diff --git a/web-ui/src/lib/detections.ts b/web-ui/src/lib/detections.ts
index 21b6153f..33a8b62e 100644
--- a/web-ui/src/lib/detections.ts
+++ b/web-ui/src/lib/detections.ts
@@ -1,8 +1,9 @@
import { createServerFn } from "@tanstack/react-start";
import { count, countDistinct, desc, sql } from "drizzle-orm";
-
import { db } from "#/db/index.ts";
import { type Detection, detections } from "#/db/schema.ts";
+import { audioUrlFor } from "#/lib/audio.ts";
+import { ebirdSearchUrl, getSpeciesInfo } from "#/lib/wikipedia.ts";
const isToday = sql`${detections.Date} = date('now', 'localtime')`;
const isLastHour = sql`datetime(${detections.Date} || ' ' || ${detections.Time}) >= datetime('now', '-1 hour', 'localtime')`;
@@ -58,24 +59,77 @@ export const getDetections = createServerFn({ method: "GET" }).handler(
},
);
-export type SpeciesSummary = {
+export type LifeListCard = {
comName: string;
sciName: string;
- count: number;
+ hourCount: number;
+ allTimeCount: number;
lastDetected: string;
+ audioUrl: string | null;
+ imageUrl: string | null;
+ wikipediaUrl: string;
+ ebirdUrl: string;
};
-export const getSpecies = createServerFn({ method: "GET" }).handler(
- async (): Promise => {
- return db
+export const getLifeListCards = createServerFn({ method: "GET" }).handler(
+ async (): Promise => {
+ const totals = await db
.select({
comName: detections.Com_Name,
sciName: detections.Sci_Name,
- count: count(),
- lastDetected: sql`max(${detections.Date})`,
+ allTimeCount: count(),
+ })
+ .from(detections)
+ .groupBy(detections.Com_Name, detections.Sci_Name);
+
+ const hourly = await db
+ .select({ comName: detections.Com_Name, hourCount: count() })
+ .from(detections)
+ .where(isLastHour)
+ .groupBy(detections.Com_Name);
+ const hourByName = new Map(
+ hourly.map((row) => [row.comName, row.hourCount]),
+ );
+
+ // Most-recent detection per species, ordered so the first occurrence
+ // of each Com_Name we see is the latest one.
+ const recent = await db
+ .select({
+ comName: detections.Com_Name,
+ date: detections.Date,
+ time: detections.Time,
+ fileName: detections.File_Name,
})
.from(detections)
- .groupBy(detections.Com_Name, detections.Sci_Name)
- .orderBy(sql`count(*) desc`);
+ .orderBy(desc(detections.Date), desc(detections.Time));
+ const latestByName = new Map<
+ string,
+ { date: string; time: string; fileName: string }
+ >();
+ for (const row of recent) {
+ if (!latestByName.has(row.comName)) {
+ latestByName.set(row.comName, row);
+ }
+ }
+
+ return Promise.all(
+ totals.map(async (row) => {
+ const latest = latestByName.get(row.comName);
+ const { imageUrl, wikipediaUrl } = await getSpeciesInfo(row.comName);
+ return {
+ comName: row.comName,
+ sciName: row.sciName,
+ allTimeCount: row.allTimeCount,
+ hourCount: hourByName.get(row.comName) ?? 0,
+ lastDetected: latest ? `${latest.date} ${latest.time}` : "",
+ audioUrl: latest
+ ? audioUrlFor(latest.date, row.comName, latest.fileName)
+ : null,
+ imageUrl,
+ wikipediaUrl,
+ ebirdUrl: ebirdSearchUrl(row.comName),
+ };
+ }),
+ );
},
);
diff --git a/web-ui/src/lib/wikipedia.ts b/web-ui/src/lib/wikipedia.ts
new file mode 100644
index 00000000..ef4fb52d
--- /dev/null
+++ b/web-ui/src/lib/wikipedia.ts
@@ -0,0 +1,53 @@
+export type SpeciesInfo = {
+ imageUrl: string | null;
+ wikipediaUrl: string;
+};
+
+type WikipediaSummary = {
+ thumbnail?: { source?: string };
+ content_urls?: { desktop?: { page?: string } };
+};
+
+// In-memory only -- cleared on server restart. Cheap insurance against
+// hammering Wikipedia on every page load for the same handful of species.
+const cache = new Map();
+
+export async function getSpeciesInfo(commonName: string): Promise {
+ const cached = cache.get(commonName);
+ if (cached) return cached;
+
+ const title = commonName.replaceAll(" ", "_");
+ const fallback: SpeciesInfo = {
+ imageUrl: null,
+ wikipediaUrl: `https://en.wikipedia.org/wiki/${encodeURIComponent(title)}`,
+ };
+
+ try {
+ const response = await fetch(
+ `https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(title)}`,
+ { signal: AbortSignal.timeout(5000) },
+ );
+ if (!response.ok) {
+ cache.set(commonName, fallback);
+ return fallback;
+ }
+ const data = (await response.json()) as WikipediaSummary;
+ const info: SpeciesInfo = {
+ imageUrl: data.thumbnail?.source ?? null,
+ wikipediaUrl: data.content_urls?.desktop?.page ?? fallback.wikipediaUrl,
+ };
+ cache.set(commonName, info);
+ return info;
+ } catch {
+ cache.set(commonName, fallback);
+ return fallback;
+ }
+}
+
+// eBird gates species/search pages behind a login wall for anonymous
+// requests, and a precise deep link needs a 6-letter species code we don't
+// have without their (key-gated) taxonomy API. A scoped web search reliably
+// gets a person to the right page regardless of their eBird auth state.
+export function ebirdSearchUrl(commonName: string): string {
+ return `https://www.google.com/search?q=${encodeURIComponent(`${commonName} ebird`)}`;
+}
diff --git a/web-ui/src/routeTree.gen.ts b/web-ui/src/routeTree.gen.ts
index 2ea5ef33..96196561 100644
--- a/web-ui/src/routeTree.gen.ts
+++ b/web-ui/src/routeTree.gen.ts
@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as DetectionsRouteImport } from './routes/detections'
import { Route as SpeciesRouteImport } from './routes/species'
+import { Route as ApiAudioDateSpeciesAndFileRouteImport } from './routes/api/audio/$date/$speciesAndFile'
const IndexRoute = IndexRouteImport.update({
id: '/',
@@ -28,35 +29,51 @@ const SpeciesRoute = SpeciesRouteImport.update({
path: '/species',
getParentRoute: () => rootRouteImport,
} as any)
+const ApiAudioDateSpeciesAndFileRoute =
+ ApiAudioDateSpeciesAndFileRouteImport.update({
+ id: '/api/audio/$date/$speciesAndFile',
+ path: '/api/audio/$date/$speciesAndFile',
+ getParentRoute: () => rootRouteImport,
+ } as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/detections': typeof DetectionsRoute
'/species': typeof SpeciesRoute
+ '/api/audio/$date/$speciesAndFile': typeof ApiAudioDateSpeciesAndFileRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/detections': typeof DetectionsRoute
'/species': typeof SpeciesRoute
+ '/api/audio/$date/$speciesAndFile': typeof ApiAudioDateSpeciesAndFileRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/detections': typeof DetectionsRoute
'/species': typeof SpeciesRoute
+ '/api/audio/$date/$speciesAndFile': typeof ApiAudioDateSpeciesAndFileRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
- fullPaths: '/' | '/detections' | '/species'
+ fullPaths:
+ '/' | '/detections' | '/species' | '/api/audio/$date/$speciesAndFile'
fileRoutesByTo: FileRoutesByTo
- to: '/' | '/detections' | '/species'
- id: '__root__' | '/' | '/detections' | '/species'
+ to: '/' | '/detections' | '/species' | '/api/audio/$date/$speciesAndFile'
+ id:
+ | '__root__'
+ | '/'
+ | '/detections'
+ | '/species'
+ | '/api/audio/$date/$speciesAndFile'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
DetectionsRoute: typeof DetectionsRoute
SpeciesRoute: typeof SpeciesRoute
+ ApiAudioDateSpeciesAndFileRoute: typeof ApiAudioDateSpeciesAndFileRoute
}
declare module '@tanstack/react-router' {
@@ -82,6 +99,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SpeciesRouteImport
parentRoute: typeof rootRouteImport
}
+ '/api/audio/$date/$speciesAndFile': {
+ id: '/api/audio/$date/$speciesAndFile'
+ path: '/api/audio/$date/$speciesAndFile'
+ fullPath: '/api/audio/$date/$speciesAndFile'
+ preLoaderRoute: typeof ApiAudioDateSpeciesAndFileRouteImport
+ parentRoute: typeof rootRouteImport
+ }
}
}
@@ -89,6 +113,7 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
DetectionsRoute: DetectionsRoute,
SpeciesRoute: SpeciesRoute,
+ ApiAudioDateSpeciesAndFileRoute: ApiAudioDateSpeciesAndFileRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
diff --git a/web-ui/src/routes/api/audio/$date/$speciesAndFile.ts b/web-ui/src/routes/api/audio/$date/$speciesAndFile.ts
new file mode 100644
index 00000000..e92ff825
--- /dev/null
+++ b/web-ui/src/routes/api/audio/$date/$speciesAndFile.ts
@@ -0,0 +1,31 @@
+import { readFile } from "node:fs/promises";
+import { createFileRoute } from "@tanstack/react-router";
+import { mimeTypeFor, resolveExtractedFile } from "#/lib/audio.server.ts";
+import { splitSpeciesAndFile } from "#/lib/audio.ts";
+
+export const Route = createFileRoute("/api/audio/$date/$speciesAndFile")({
+ server: {
+ handlers: {
+ GET: async ({ params }) => {
+ const [species, file] = splitSpeciesAndFile(params.speciesAndFile);
+ const relativePath = `By_Date/${params.date}/${species}/${file}`;
+ const resolved = resolveExtractedFile(relativePath);
+ if (!resolved) {
+ return new Response("Not found", { status: 404 });
+ }
+
+ try {
+ const data = await readFile(resolved);
+ return new Response(data, {
+ headers: {
+ "Content-Type": mimeTypeFor(resolved),
+ "Cache-Control": "public, max-age=31536000, immutable",
+ },
+ });
+ } catch {
+ return new Response("Not found", { status: 404 });
+ }
+ },
+ },
+ },
+});
diff --git a/web-ui/src/routes/species.tsx b/web-ui/src/routes/species.tsx
index 714a0bf8..3cfd3b75 100644
--- a/web-ui/src/routes/species.tsx
+++ b/web-ui/src/routes/species.tsx
@@ -1,66 +1,307 @@
import { createFileRoute } from "@tanstack/react-router";
+import {
+ ArrowDownAZ,
+ Binoculars,
+ Bird,
+ BookOpen,
+ Clock,
+ Loader2,
+ Mic2,
+ Pause,
+ Play,
+} from "lucide-react";
+import { useEffect, useMemo, useRef, useState } from "react";
-import { Badge } from "#/components/ui/badge.tsx";
+import { Button } from "#/components/ui/button.tsx";
+import { Input } from "#/components/ui/input.tsx";
import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "#/components/ui/table.tsx";
-import { getSpecies } from "#/lib/detections.ts";
+ Pagination,
+ PaginationContent,
+ PaginationItem,
+ PaginationLink,
+ PaginationNext,
+ PaginationPrevious,
+} from "#/components/ui/pagination.tsx";
+import { ToggleGroup, ToggleGroupItem } from "#/components/ui/toggle-group.tsx";
+import { getLifeListCards, type LifeListCard } from "#/lib/detections.ts";
export const Route = createFileRoute("/species")({
component: Species,
- loader: () => getSpecies(),
+ loader: () => getLifeListCards(),
});
+type SortKey = "count" | "recent" | "alpha";
+
+const PAGE_SIZE = 24;
+
function Species() {
- const species = Route.useLoaderData();
+ const cards = Route.useLoaderData();
+ const [search, setSearch] = useState("");
+ const [sort, setSort] = useState("count");
+ const [page, setPage] = useState(1);
+
+ const filtered = useMemo(() => {
+ const query = search.trim().toLowerCase();
+ const matches = query
+ ? cards.filter(
+ (c) =>
+ c.comName.toLowerCase().includes(query) ||
+ c.sciName.toLowerCase().includes(query),
+ )
+ : cards;
+
+ return [...matches].sort((a, b) => {
+ if (sort === "alpha") return a.comName.localeCompare(b.comName);
+ if (sort === "recent")
+ return b.lastDetected.localeCompare(a.lastDetected);
+ return b.allTimeCount - a.allTimeCount;
+ });
+ }, [cards, search, sort]);
+
+ const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
+ const currentPage = Math.min(page, pageCount);
+ const pageItems = filtered.slice(
+ (currentPage - 1) * PAGE_SIZE,
+ currentPage * PAGE_SIZE,
+ );
return (
Species
- {species.length} species detected so far.
+ {cards.length} species detected so far.
-
-
-
-
- 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 loading doesn't reliably reach
+ // this app's dynamic audio route (some Sec-Fetch-Dest-specific
+ // behavior in this dev stack), but a plain fetch() always does --
+ // so fetch it ourselves and play from a local blob instead.
+ if (!objectUrlRef.current) {
+ setIsLoading(true);
+ try {
+ const response = await fetch(card.audioUrl);
+ if (!response.ok) throw new Error("Failed to fetch audio");
+ objectUrlRef.current = URL.createObjectURL(await response.blob());
+ audio.src = objectUrlRef.current;
+ } catch {
+ setIsLoading(false);
+ return;
+ }
+ setIsLoading(false);
+ }
+
+ audio.play().catch(() => setIsPlaying(false));
+ }
+
+ return (
+
+
+
{card.comName}
+
{card.sciName}
+
+
+
+ {card.imageUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+
+
{card.hourCount}
+
This hour
+
+
+
+ {card.allTimeCount}
+
+
All time
+
+
+
+
+
+ {isLoading ? (
+
+ ) : isPlaying ? (
+
) : (
- species.map((s) => (
-
- {s.comName}
- {s.sciName}
-
- {s.lastDetected}
-
-
-
- {s.count}
-
-
-
- ))
+
)}
-
-
+ {isPlaying ? "Pause" : "Play"}
+
+
+
+
+ Wiki
+
+
+
+
+
+ eBird
+
+
+ {card.audioUrl && (
+
setIsPlaying(true)}
+ onPause={() => setIsPlaying(false)}
+ onEnded={() => setIsPlaying(false)}
+ >
+
+
+ )}
+
);
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