Move Species page filter/sort/page state into the URL - #4
Merged
Conversation
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 <noreply@anthropic.com>
…udio 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 <audio src> 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR updates the Species page so its filter/sort/pagination state is URL-driven (?q=&sort=&page=) via a Zod-validated search schema + stripSearchParams, making the view shareable and resilient to back/forward and cold loads. It also includes supporting UI/components and new server routes/utilities to power the updated Species card grid (including audio streaming and species metadata).
Changes:
- Move Species page query/sort/page state from local React state into validated route search params, with defaults stripped from the URL.
- Add supporting UI primitives (Input, Toggle/ToggleGroup, Pagination) and a Species card-grid with audio playback controls.
- Add an audio streaming server route plus shared helpers, Wikipedia lookup helper, and seed script audio generation updates.
Reviewed changes
Copilot reviewed 18 out of 19 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| web-ui/src/styles.css | Adjust global radius token, add scrollbar-gutter: stable, remove paper-grain background. |
| web-ui/src/routeTree.gen.ts | Register the new /api/audio/$date/$speciesAndFile route in the generated route tree. |
| web-ui/src/routes/species.tsx | Rebuild Species UI into a URL-driven card grid with search/sort/pagination and audio/wiki/eBird actions. |
| web-ui/src/routes/index.tsx | Remove HeroBand usage and tweak spacing/rounding. |
| web-ui/src/routes/detections.tsx | Minor rounding token update for the table container. |
| web-ui/src/routes/api/audio/$date/$speciesAndFile.ts | Add server GET handler to stream extracted audio clips from disk. |
| web-ui/src/lib/wikipedia.ts | Add cached Wikipedia summary lookup + eBird search URL helper. |
| web-ui/src/lib/detections.ts | Replace species summary loader with richer “life list card” loader (counts, latest detection, image/wiki/audio URLs). |
| web-ui/src/lib/audio.ts | Add isomorphic helpers to build/decode audio route URLs (base64url segment encoding). |
| web-ui/src/lib/audio.server.ts | Add server-only helpers for extracted-dir resolution and MIME type selection. |
| web-ui/src/db/index.ts | Mark DB module as server-only. |
| web-ui/src/components/ui/toggle.tsx | Add Toggle primitive wrapper with variants. |
| web-ui/src/components/ui/toggle-group.tsx | Add ToggleGroup wrapper with shared variant/size context. |
| web-ui/src/components/ui/pagination.tsx | Add Pagination UI components. |
| web-ui/src/components/ui/input.tsx | Add styled Input component. |
| web-ui/src/components/HeroBand.tsx | Remove unused hero SVG component. |
| web-ui/package.json | Add zod dependency. |
| web-ui/package-lock.json | Lockfile update for zod addition. |
| scripts/seed_test_data.py | Add placeholder audio clip generation and CLI flags for extracted-dir / disabling audio generation. |
Files not reviewed (1)
- web-ui/package-lock.json: Generated file
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+9
to
+12
| GET: async ({ params }) => { | ||
| const [species, file] = splitSpeciesAndFile(params.speciesAndFile); | ||
| const relativePath = `By_Date/${params.date}/${species}/${file}`; | ||
| const resolved = resolveExtractedFile(relativePath); |
Comment on lines
+38
to
+41
| type PaginationLinkProps = { | ||
| isActive?: boolean; | ||
| } & Pick<React.ComponentProps<typeof Button>, "size"> & | ||
| React.ComponentProps<"a">; |
Comment on lines
+35
to
+40
| data-spacing={spacing} | ||
| style={{ "--gap": spacing } as React.CSSProperties} | ||
| className={cn( | ||
| "group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs", | ||
| className, | ||
| )} |
Comment on lines
+126
to
+129
| onChange={(e) => { | ||
| const value = e.target.value; | ||
| navigate({ search: (prev) => ({ ...prev, q: value, page: 1 }) }); | ||
| }} |
Comment on lines
+328
to
+336
| <audio | ||
| ref={audioRef} | ||
| preload="none" | ||
| onPlay={() => setIsPlaying(true)} | ||
| onPause={() => setIsPlaying(false)} | ||
| onEnded={() => setIsPlaying(false)} | ||
| > | ||
| <track kind="captions" /> | ||
| </audio> |
| 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) |
Comment on lines
+115
to
+119
| return Promise.all( | ||
| totals.map(async (row) => { | ||
| const latest = latestByName.get(row.comName); | ||
| const { imageUrl, wikipediaUrl } = await getSpeciesInfo(row.comName); | ||
| return { |
Comment on lines
+94
to
+104
| // 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)); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Depends on #2 (this branch is stacked on it — the diff below will shrink to just this change once #2 merges).
Retrofits the Species page's search box, sort toggle, and pagination from local component state (
useState) to the route's search params (?q=&sort=&page=), matching the same pattern used on the new Stats page (#3): a Zod-validated search schema with.default().catch(), andstripSearchParamsto keep the URL clean when everything's at its default.Filtering/sorting/pagination is now shareable, bookmarkable, and survives browser back/forward — loading
/species?sort=alphadirectly (no client interaction at all) renders correctly sorted from a cold page load.Test plan
tsc --noEmitandbiome checkcleannpm run buildsucceedsCo-Authored-By: Claude Sonnet 5 noreply@anthropic.com