# Search, Nav, Management & Error States — Design Spec **Date:** 2026-04-08 **Scope:** Backend search service, frontend nav shell, song management (edit + delete), error states. --- ## Context PocketChords has working persistence and a functional chord viewer. This iteration makes the app feel polished and complete: server-side search, consistent navigation, song management, and proper error handling. --- ## 1. Backend: Search Service ### Architecture A dedicated `SongSearchService` with its own port — fully decoupled from `SongService`. Search and CRUD are independently swappable. ``` GET /songs?q=… → SongSearchService → Box → SqliteSongRepository GET /songs → SongService → Box → SqliteSongRepository ``` `SqliteSongRepository` implements both ports — one struct, two trait impls. ### New domain port (`crates/domain/src/ports.rs`) ```rust #[async_trait] pub trait SongSearchPort: Send + Sync { async fn search(&self, query: &str) -> Result, RepositoryError>; } ``` ### `SqliteSongRepository` search impl (`crates/infrastructure/persistence/src/lib.rs`) SQLite `LIKE` query on title and artist columns: ```sql SELECT id, title, artist, original_key, preview_chords FROM songs WHERE title LIKE ? OR artist LIKE ? ORDER BY created_at DESC ``` Bind parameter: `format!("%{}%", query)` for both. ### New `SongSearchService` (`crates/common/src/lib.rs`) ```rust pub struct SongSearchService { search: Box, } impl SongSearchService { pub fn new(search: Box) -> Self pub async fn search(&self, query: &str) -> Result, RepositoryError> } ``` ### `AppState` update (`crates/api/src/routes/tabs.rs`) ```rust pub struct AppState { pub fetcher: Box, pub parser: Box, pub songs: SongService, pub search: SongSearchService, } ``` ### API endpoint update (`crates/api/src/routes/songs.rs`) `GET /songs` — branches on presence of `q` query param: ```rust #[derive(Deserialize)] pub struct ListQuery { pub q: Option } pub async fn list_songs( State(state): State>, Query(params): Query, ) -> Result>, ...> { if let Some(q) = params.q.filter(|s| !s.is_empty()) { state.search.search(&q).await ... } else { state.songs.list().await ... } } ``` ### `main.rs` wiring Use a single `Arc` shared between both services — avoids two connection pools: ```rust use std::sync::Arc; let repo = Arc::new(SqliteRepositoryFactory::create(&database_url).await?); let songs = SongService::new(Box::new(Arc::clone(&repo))); let search = SongSearchService::new(Box::new(Arc::clone(&repo))); ``` Requires `SqliteSongRepository` to implement both ports, and `Arc` to implement them via blanket delegation. In practice: implement the traits on `Arc` directly, or on `SqliteSongRepository` and add `#[async_trait] impl SongRepositoryPort for Arc { ... }` forwarding impls. --- ## 2. Backend: Edit endpoint ### New endpoint `PATCH /songs/:id` — updates mutable metadata fields only. **Request body:** ```json { "title": "New Title", "artist": "New Artist", "original_key": "Am" } ``` All fields optional. Only provided fields are updated. **Response:** `200 OK` with updated `SongSummary`. ### Domain port update ```rust pub trait SongRepositoryPort: Send + Sync { async fn save(&self, song: &Song) -> Result; async fn list(&self) -> Result, RepositoryError>; async fn get(&self, id: Uuid) -> Result, RepositoryError>; async fn delete(&self, id: Uuid) -> Result<(), RepositoryError>; async fn update_meta(&self, id: Uuid, title: Option<&str>, artist: Option<&str>, original_key: Option<&str>) -> Result; } ``` ### `SongService` gains `update_meta` Delegates to repo. Also updates `body` JSON so the full Song stays in sync: ```sql UPDATE songs SET title = COALESCE(?, title), artist = COALESCE(?, artist), original_key = COALESCE(?, original_key), body = ? WHERE id = ? ``` Deserializes `body`, patches `meta`, re-serializes, writes back. ### New handler `update_song` in `songs.rs` ```rust pub async fn update_song( State(state): State>, Path(id): Path, Json(body): Json, ) -> Result, (StatusCode, Json)> ``` --- ## 3. Frontend: Layout & Nav ### New files - `app/app/routes/layout.tsx` — parent route shell with bottom tab bar - `app/app/components/bottom-nav.tsx` — single Library tab ### Route config update (`app/app/routes.ts`) ```ts import { type RouteConfig, index, layout, route } from "@react-router/dev/routes"; export default [ layout("routes/layout.tsx", [ index("routes/home.tsx"), route("songs/:id", "routes/songs.$id.tsx"), ]), ] satisfies RouteConfig; ``` ### `layout.tsx` ```tsx export default function Layout() { return (
); } ``` ### `bottom-nav.tsx` Single tab: Library icon + label, links to `/`, highlights when active (`useLocation`). ```tsx ``` Uses `NavLink` from react-router for active state styling. --- ## 4. Frontend: Live Search ### `home.tsx` changes - Remove `useState(query)` client-side filter - Add `useSearchParams` hook — search term lives in URL (`?q=…`) - Debounce input changes (300ms) before updating URL param - Loader reads `q` from `request.url` and calls `listSongs(q)` or `listSongs()` ```ts // loader export async function loader({ request }: Route.LoaderArgs) { const q = new URL(request.url).searchParams.get("q") ?? ""; const songs = await listSongs(q); return { songs, q }; } ``` ```ts // api.ts export async function listSongs(q = ""): Promise { const url = q ? `${API_BASE}/songs?q=${encodeURIComponent(q)}` : `${API_BASE}/songs`; ... } ``` Component uses `useNavigate` + `useSearchParams` + debounced `setSearchParams`. --- ## 5. Frontend: Song Management ### `TransposeBar` update Add `onEdit` and `onDelete` prop callbacks. Add `DropdownMenu` (shadcn) triggered by a `MoreHorizontal` icon button in the header row. Menu items: - **Edit** → calls `onEdit()` - **Delete** → calls `onDelete()` ### New `EditSongSheet` component (`app/app/components/edit-song-sheet.tsx`) Bottom `Sheet` with three inputs: Title, Artist, Key. Pre-filled from current `SongMeta`. Submit calls `updateSong(id, { title, artist, original_key })` → updates `song.meta` in component state → closes sheet. ### New `DeleteSongDialog` component (`app/app/components/delete-song-dialog.tsx`) `AlertDialog` (shadcn): "Are you sure? This cannot be undone." Confirm → `deleteSong(id)` → navigate to `/`. ### `songs.$id.tsx` changes - Import `EditSongSheet`, `DeleteSongDialog` - Track `editOpen`, `deleteOpen` state - Pass `onEdit`/`onDelete` to `TransposeBar` ### New `api.ts` helpers ```ts export async function updateSong(id: string, patch: { title?: string; artist?: string; original_key?: string; }): Promise export async function deleteSong(id: string): Promise // already exists ``` --- ## 6. Frontend: Error States ### Library (`home.tsx`) Loader catches API errors and returns `{ songs: [], error: true }`. Component shows inline error when `error` is true: ```tsx {loaderData.error && (

Couldn't load your songs. Is the API running?

)} ``` Uses `useRevalidator` from react-router for the retry. ### Song detail (`songs.$id.tsx`) `getSong` returns `null` on 404 or throws on network error. Loader returns `{ song: null }` on any failure. Component shows: ```tsx {!song && (

Song not found or unavailable.

← Back to library
)} ``` ### Transient errors (toasts) `sonner` is already in the project. Import `toast` from `sonner`. Fire on: - Add song failure: `toast.error("Failed to import song", { description: err.message })` - Delete failure: `toast.error("Failed to delete song")` - Edit failure: `toast.error("Failed to save changes")` Add `` to `layout.tsx` (one location, covers all pages). --- ## New/Modified Files Summary **Rust:** - `crates/domain/src/ports.rs` — add `SongSearchPort`, add `update_meta` to `SongRepositoryPort` - `crates/domain/src/lib.rs` — re-export `SongSearchPort` - `crates/infrastructure/persistence/src/lib.rs` — impl `SongSearchPort`, impl `update_meta` - `crates/common/src/lib.rs` — add `SongSearchService`, add `SongService::update_meta` - `crates/api/src/routes/tabs.rs` — add `search: SongSearchService` to `AppState` - `crates/api/src/routes/songs.rs` — update `list_songs` for `?q=`, add `update_song` - `crates/api/src/main.rs` — wire `SongSearchService`, add `PATCH /songs/{id}` **Frontend:** - `app/app/routes.ts` — add layout route - `app/app/routes/layout.tsx` — new shell with `` + `` - `app/app/components/bottom-nav.tsx` — new single-tab nav - `app/app/routes/home.tsx` — URL-based search params, loader uses `q` - `app/app/routes/songs.$id.tsx` — edit/delete integration, null error state - `app/app/components/transpose-bar.tsx` — add DropdownMenu with Edit/Delete - `app/app/components/edit-song-sheet.tsx` — new edit sheet - `app/app/components/delete-song-dialog.tsx` — new confirm dialog - `app/app/lib/api.ts` — update `listSongs(q?)`, add `updateSong` - `app/app/lib/types.ts` — add `UpdateSongRequest` --- ## Verification 1. `cargo build --workspace` — clean 2. `cargo test --workspace` — all pass 3. `GET /songs?q=ocean` returns songs matching title/artist 4. `PATCH /songs/:id` with `{ "title": "New" }` updates title, leaves rest unchanged 5. Library search input debounces — network tab shows requests fire 300ms after typing stops 6. Song detail `⋯` menu shows Edit and Delete 7. Edit sheet pre-fills current values, saves successfully 8. Delete dialog navigates back to library on confirm 9. With API stopped: library shows "Couldn't load" inline + Retry; detail shows "← Back" 10. Failed add/delete fires a sonner toast 11. Bottom nav tab highlights on `/`, not highlighted on `/songs/:id` 12. `npm run typecheck` — clean