11 KiB
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<dyn SongSearchPort> → SqliteSongRepository
GET /songs → SongService → Box<dyn SongRepositoryPort> → SqliteSongRepository
SqliteSongRepository implements both ports — one struct, two trait impls.
New domain port (crates/domain/src/ports.rs)
#[async_trait]
pub trait SongSearchPort: Send + Sync {
async fn search(&self, query: &str) -> Result<Vec<SongSummary>, RepositoryError>;
}
SqliteSongRepository search impl (crates/infrastructure/persistence/src/lib.rs)
SQLite LIKE query on title and artist columns:
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)
pub struct SongSearchService {
search: Box<dyn SongSearchPort>,
}
impl SongSearchService {
pub fn new(search: Box<dyn SongSearchPort>) -> Self
pub async fn search(&self, query: &str) -> Result<Vec<SongSummary>, RepositoryError>
}
AppState update (crates/api/src/routes/tabs.rs)
pub struct AppState {
pub fetcher: Box<dyn TabFetcherPort>,
pub parser: Box<dyn TabParserPort>,
pub songs: SongService,
pub search: SongSearchService,
}
API endpoint update (crates/api/src/routes/songs.rs)
GET /songs — branches on presence of q query param:
#[derive(Deserialize)]
pub struct ListQuery { pub q: Option<String> }
pub async fn list_songs(
State(state): State<Arc<AppState>>,
Query(params): Query<ListQuery>,
) -> Result<Json<Vec<SongSummary>>, ...> {
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<SqliteSongRepository> shared between both services — avoids two connection pools:
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<SqliteSongRepository> to implement them via blanket delegation. In practice: implement the traits on Arc<SqliteSongRepository> directly, or on SqliteSongRepository and add #[async_trait] impl SongRepositoryPort for Arc<SqliteSongRepository> { ... } forwarding impls.
2. Backend: Edit endpoint
New endpoint
PATCH /songs/:id — updates mutable metadata fields only.
Request body:
{ "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
pub trait SongRepositoryPort: Send + Sync {
async fn save(&self, song: &Song) -> Result<StoredSong, RepositoryError>;
async fn list(&self) -> Result<Vec<SongSummary>, RepositoryError>;
async fn get(&self, id: Uuid) -> Result<Option<Song>, 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<SongSummary, RepositoryError>;
}
SongService gains update_meta
Delegates to repo. Also updates body JSON so the full Song stays in sync:
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
pub async fn update_song(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
Json(body): Json<UpdateSongRequest>,
) -> Result<Json<SongSummary>, (StatusCode, Json<ErrorResponse>)>
3. Frontend: Layout & Nav
New files
app/app/routes/layout.tsx— parent route shell with bottom tab barapp/app/components/bottom-nav.tsx— single Library tab
Route config update (app/app/routes.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
export default function Layout() {
return (
<div className="flex flex-col h-dvh">
<div className="flex-1 overflow-hidden">
<Outlet />
</div>
<BottomNav />
</div>
);
}
bottom-nav.tsx
Single tab: Library icon + label, links to /, highlights when active (useLocation).
<nav className="border-t bg-background">
<NavLink to="/" className={({ isActive }) => ...}>
<Music className="w-5 h-5" />
<span className="text-xs">Library</span>
</NavLink>
</nav>
Uses NavLink from react-router for active state styling.
4. Frontend: Live Search
home.tsx changes
- Remove
useState(query)client-side filter - Add
useSearchParamshook — search term lives in URL (?q=…) - Debounce input changes (300ms) before updating URL param
- Loader reads
qfromrequest.urland callslistSongs(q)orlistSongs()
// 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 };
}
// api.ts
export async function listSongs(q = ""): Promise<SongSummary[]> {
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,deleteOpenstate - Pass
onEdit/onDeletetoTransposeBar
New api.ts helpers
export async function updateSong(id: string, patch: {
title?: string; artist?: string; original_key?: string;
}): Promise<SongSummary>
export async function deleteSong(id: string): Promise<void> // 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:
{loaderData.error && (
<div className="flex flex-col items-center gap-3 pt-12 text-center px-6">
<p className="text-sm text-muted-foreground">
Couldn't load your songs. Is the API running?
</p>
<Button variant="outline" size="sm" onClick={() => revalidator.revalidate()}>
Retry
</Button>
</div>
)}
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:
{!song && (
<div className="flex flex-col items-center justify-center h-full gap-4">
<p className="text-muted-foreground">Song not found or unavailable.</p>
<Link to="/" className="text-sm text-primary">← Back to library</Link>
</div>
)}
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 <Toaster /> to layout.tsx (one location, covers all pages).
New/Modified Files Summary
Rust:
crates/domain/src/ports.rs— addSongSearchPort, addupdate_metatoSongRepositoryPortcrates/domain/src/lib.rs— re-exportSongSearchPortcrates/infrastructure/persistence/src/lib.rs— implSongSearchPort, implupdate_metacrates/common/src/lib.rs— addSongSearchService, addSongService::update_metacrates/api/src/routes/tabs.rs— addsearch: SongSearchServicetoAppStatecrates/api/src/routes/songs.rs— updatelist_songsfor?q=, addupdate_songcrates/api/src/main.rs— wireSongSearchService, addPATCH /songs/{id}
Frontend:
app/app/routes.ts— add layout routeapp/app/routes/layout.tsx— new shell with<Outlet>+<BottomNav>app/app/components/bottom-nav.tsx— new single-tab navapp/app/routes/home.tsx— URL-based search params, loader usesqapp/app/routes/songs.$id.tsx— edit/delete integration, null error stateapp/app/components/transpose-bar.tsx— add DropdownMenu with Edit/Deleteapp/app/components/edit-song-sheet.tsx— new edit sheetapp/app/components/delete-song-dialog.tsx— new confirm dialogapp/app/lib/api.ts— updatelistSongs(q?), addupdateSongapp/app/lib/types.ts— addUpdateSongRequest
Verification
cargo build --workspace— cleancargo test --workspace— all passGET /songs?q=oceanreturns songs matching title/artistPATCH /songs/:idwith{ "title": "New" }updates title, leaves rest unchanged- Library search input debounces — network tab shows requests fire 300ms after typing stops
- Song detail
⋯menu shows Edit and Delete - Edit sheet pre-fills current values, saves successfully
- Delete dialog navigates back to library on confirm
- With API stopped: library shows "Couldn't load" inline + Retry; detail shows "← Back"
- Failed add/delete fires a sonner toast
- Bottom nav tab highlights on
/, not highlighted on/songs/:id npm run typecheck— clean