From c3b7cb78abc87de34e432ebd2e51851b28732e8c Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Wed, 8 Apr 2026 03:32:56 +0200 Subject: [PATCH] docs: add search/nav/management implementation plan --- .../plans/2026-04-08-search-nav-management.md | 1122 +++++++++++++++++ 1 file changed, 1122 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-08-search-nav-management.md diff --git a/docs/superpowers/plans/2026-04-08-search-nav-management.md b/docs/superpowers/plans/2026-04-08-search-nav-management.md new file mode 100644 index 0000000..46f1acf --- /dev/null +++ b/docs/superpowers/plans/2026-04-08-search-nav-management.md @@ -0,0 +1,1122 @@ +# Search, Nav, Management & Error States — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add server-side search (dedicated `SongSearchService`), bottom nav shell, song edit/delete management, and proper error states. + +**Architecture:** Backend — `SongSearchPort` + `SongSearchService` decouple search from CRUD; `update_meta` extends the repository port; `SqliteSongRepository` is made `Clone` so a single pool is shared. Frontend — a layout route wraps all pages with a bottom nav bar; home uses URL-based search with debounce; song detail gains a `⋯` menu; errors show inline or as toasts. + +**Tech Stack:** Rust/Axum, SQLx/SQLite, React Router 7, TailwindCSS, shadcn/ui (DropdownMenu, AlertDialog, Toaster from sonner). + +--- + +## File Map + +**New Rust:** +- `crates/domain/src/ports.rs` — add `SongSearchPort`, `update_meta` to `SongRepositoryPort` +- `crates/common/src/lib.rs` — add `SongSearchService`, `SongService::update_meta` +- `crates/infrastructure/persistence/src/lib.rs` — derive `Clone`, impl `SongSearchPort`, impl `update_meta` +- `crates/api/src/routes/songs.rs` — update `list_songs` with `?q=`, add `update_song` +- `crates/api/src/routes/tabs.rs` — add `search: SongSearchService` to `AppState` +- `crates/api/src/main.rs` — wire `SongSearchService`, add `PATCH /songs/{id}` + +**New Frontend:** +- `app/app/routes/layout.tsx` — shell with `` + `` + `` +- `app/app/components/bottom-nav.tsx` — single Library tab +- `app/app/components/edit-song-sheet.tsx` — edit title/artist/key sheet +- `app/app/components/delete-song-dialog.tsx` — confirm delete AlertDialog + +**Modified Frontend:** +- `app/app/routes.ts` — wrap routes in layout +- `app/app/routes/home.tsx` — URL-based search, error state, revalidator +- `app/app/routes/songs.$id.tsx` — edit/delete integration, error fallback +- `app/app/components/transpose-bar.tsx` — add `onEdit`/`onDelete` + DropdownMenu +- `app/app/lib/api.ts` — `listSongs(q?)`, `updateSong` +- `app/app/lib/types.ts` — add `UpdateSongRequest` + +--- + +## Task 1: Backend search service + +**Files:** +- Modify: `crates/domain/src/ports.rs` +- Modify: `crates/domain/src/lib.rs` +- Modify: `crates/common/src/lib.rs` +- Modify: `crates/infrastructure/persistence/src/lib.rs` +- Modify: `crates/api/src/routes/tabs.rs` +- Modify: `crates/api/src/routes/songs.rs` +- Modify: `crates/api/src/main.rs` + +- [ ] **Add `SongSearchPort` to `crates/domain/src/ports.rs`** (append after `SongRepositoryPort`): + +```rust +#[async_trait] +pub trait SongSearchPort: Send + Sync { + async fn search(&self, query: &str) -> Result, RepositoryError>; +} +``` + +- [ ] **Re-export `SongSearchPort` in `crates/domain/src/lib.rs`**: + +```rust +pub use ports::{FetchError, ParseError, RepositoryError, SongRepositoryPort, SongSearchPort, TabFetcherPort, TabParserPort, TabSource}; +``` + +- [ ] **Add `SongSearchService` to `crates/common/src/lib.rs`** (append after `SongService`): + +```rust +use domain::SongSearchPort; + +pub struct SongSearchService { + search: Box, +} + +impl SongSearchService { + pub fn new(search: Box) -> Self { + Self { search } + } + + pub async fn search(&self, query: &str) -> Result, domain::RepositoryError> { + self.search.search(query).await + } +} +``` + +- [ ] **Derive `Clone` on `SqliteSongRepository` and implement `SongSearchPort`** in `crates/infrastructure/persistence/src/lib.rs`: + +Add `#[derive(Clone)]` to `SqliteSongRepository`: +```rust +#[derive(Clone)] +pub struct SqliteSongRepository { + pool: SqlitePool, +} +``` + +Append at the bottom of the file: +```rust +use domain::SongSearchPort; + +#[async_trait] +impl SongSearchPort for SqliteSongRepository { + async fn search(&self, query: &str) -> Result, RepositoryError> { + let pattern = format!("%{}%", query); + let rows = sqlx::query_as::<_, SongRow>( + "SELECT id, title, artist, original_key, preview_chords, body FROM songs \ + WHERE title LIKE ? OR artist LIKE ? ORDER BY created_at DESC" + ) + .bind(&pattern) + .bind(&pattern) + .fetch_all(&self.pool) + .await + .map_err(|e| RepositoryError::Internal(e.to_string()))?; + + rows.into_iter() + .map(|row| { + let id = Uuid::parse_str(&row.id) + .map_err(|e| RepositoryError::Internal(e.to_string()))?; + let preview_chords: Vec = serde_json::from_str(&row.preview_chords) + .map_err(|e| RepositoryError::Internal(e.to_string()))?; + Ok(SongSummary { + id, + meta: SongMeta { + title: row.title, + artist: row.artist, + original_key: row.original_key, + capo: None, + tuning: None, + tempo: None, + }, + preview_chords, + }) + }) + .collect() + } +} +``` + +- [ ] **Add `search: SongSearchService` to `AppState`** in `crates/api/src/routes/tabs.rs`: + +```rust +pub struct AppState { + pub fetcher: Box, + pub parser: Box, + pub songs: common::SongService, + pub search: common::SongSearchService, +} +``` + +- [ ] **Update `list_songs` to branch on `?q=`** in `crates/api/src/routes/songs.rs`: + +Add import at top: +```rust +use axum::extract::Query; +use serde::Deserialize; +``` + +Add struct before `list_songs`: +```rust +#[derive(Deserialize)] +pub struct ListQuery { + pub q: Option, +} +``` + +Replace `list_songs`: +```rust +pub async fn list_songs( + State(state): State>, + Query(params): Query, +) -> Result>, (StatusCode, Json)> { + let result = if let Some(q) = params.q.filter(|s| !s.is_empty()) { + state.search.search(&q).await + } else { + state.songs.list().await + }; + result + .map(Json) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e.to_string() }))) +} +``` + +- [ ] **Wire `SongSearchService` in `crates/api/src/main.rs`**: + +```rust +mod routes; + +use axum::{Router, routing::{delete, get, patch, post}}; +use common::{SongSearchService, SongService}; +use persistence::SqliteRepositoryFactory; +use routes::songs::{create_song, delete_song, get_song, list_songs, update_song}; +use routes::tabs::{AppState, parse_tab}; +use std::sync::Arc; +use tower_http::cors::{Any, CorsLayer}; +use ug_parser::{UgHtmlParser, UgTabFetcher}; + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt::init(); + + let database_url = std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "sqlite://./pocket-chords.db".into()); + let repo = SqliteRepositoryFactory::create(&database_url) + .await + .expect("failed to connect to database"); + let songs = SongService::new(Box::new(repo.clone())); + let search = SongSearchService::new(Box::new(repo)); + + let state = Arc::new(AppState { + fetcher: Box::new(UgTabFetcher::new()), + parser: Box::new(UgHtmlParser), + songs, + search, + }); + + let cors = CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any); + + let app = Router::new() + .route("/tabs/parse", post(parse_tab)) + .route("/songs", post(create_song).get(list_songs)) + .route("/songs/{id}", get(get_song).delete(delete_song).patch(update_song)) + .layer(cors) + .with_state(state); + + let listener = tokio::net::TcpListener::bind("0.0.0.0:8000").await.unwrap(); + tracing::info!("listening on {}", listener.local_addr().unwrap()); + axum::serve(listener, app).await.unwrap(); +} +``` + +- [ ] **Build and test** + +```bash +cd /mnt/drive/dev/pocket-chords && cargo build --workspace 2>&1 | tail -5 +cargo test --workspace 2>&1 | tail -5 +``` + +Expected: clean build, all tests pass. + +- [ ] **Smoke test search endpoint** + +```bash +cd /mnt/drive/dev/pocket-chords && DATABASE_URL=sqlite://./pocket-chords.db cargo run -p api & +sleep 2 +curl -s "http://localhost:8000/songs?q=ocean" | head -c 200 +kill %1 +``` + +Expected: JSON array (may be empty if DB is fresh, non-empty if songs exist). + +- [ ] **Commit** + +```bash +cd /mnt/drive/dev/pocket-chords +git add crates/ +git commit -m "feat: add SongSearchService and GET /songs?q= search endpoint" +``` + +--- + +## Task 2: Backend edit endpoint + +**Files:** +- Modify: `crates/domain/src/ports.rs` +- Modify: `crates/common/src/lib.rs` +- Modify: `crates/infrastructure/persistence/src/lib.rs` +- Modify: `crates/api/src/routes/songs.rs` + +- [ ] **Add `update_meta` to `SongRepositoryPort`** in `crates/domain/src/ports.rs`: + +```rust +#[async_trait] +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; +} +``` + +- [ ] **Add `update_meta` to `SongService`** in `crates/common/src/lib.rs`: + +```rust +pub async fn update_meta( + &self, + id: Uuid, + title: Option<&str>, + artist: Option<&str>, + original_key: Option<&str>, +) -> Result { + self.repo.update_meta(id, title, artist, original_key).await +} +``` + +- [ ] **Implement `update_meta` on `SqliteSongRepository`** in `crates/infrastructure/persistence/src/lib.rs`: + +Add inside the `impl SongRepositoryPort for SqliteSongRepository` block: +```rust +async fn update_meta( + &self, + id: Uuid, + title: Option<&str>, + artist: Option<&str>, + original_key: Option<&str>, +) -> Result { + let id_str = id.to_string(); + + // Fetch current row + let row = sqlx::query_as::<_, SongRow>( + "SELECT id, title, artist, original_key, preview_chords, body FROM songs WHERE id = ?" + ) + .bind(&id_str) + .fetch_optional(&self.pool) + .await + .map_err(|e| RepositoryError::Internal(e.to_string()))? + .ok_or(RepositoryError::NotFound)?; + + // Patch the body JSON + let mut song: Song = serde_json::from_str(&row.body) + .map_err(|e| RepositoryError::Internal(e.to_string()))?; + if let Some(t) = title { song.meta.title = t.to_string(); } + if let Some(a) = artist { song.meta.artist = a.to_string(); } + if let Some(k) = original_key { song.meta.original_key = Some(k.to_string()); } + let new_body = serde_json::to_string(&song) + .map_err(|e| RepositoryError::Internal(e.to_string()))?; + + let new_title = title.unwrap_or(&row.title); + let new_artist = artist.unwrap_or(&row.artist); + let new_key = original_key.or(row.original_key.as_deref()); + + sqlx::query( + "UPDATE songs SET title = ?, artist = ?, original_key = ?, body = ? WHERE id = ?" + ) + .bind(new_title) + .bind(new_artist) + .bind(new_key) + .bind(&new_body) + .bind(&id_str) + .execute(&self.pool) + .await + .map_err(|e| RepositoryError::Internal(e.to_string()))?; + + let preview_chords: Vec = serde_json::from_str(&row.preview_chords) + .map_err(|e| RepositoryError::Internal(e.to_string()))?; + + Ok(SongSummary { + id, + meta: song.meta, + preview_chords, + }) +} +``` + +- [ ] **Add `update_song` handler** in `crates/api/src/routes/songs.rs`: + +Add import: `use axum::extract::Path;` (already present). Add: +```rust +#[derive(serde::Deserialize)] +pub struct UpdateSongRequest { + pub title: Option, + pub artist: Option, + pub original_key: Option, +} + +pub async fn update_song( + State(state): State>, + Path(id): Path, + Json(body): Json, +) -> Result, (StatusCode, Json)> { + let uuid = Uuid::parse_str(&id).map_err(|_| { + (StatusCode::BAD_REQUEST, Json(ErrorResponse { error: "Invalid ID".into() })) + })?; + + state.songs + .update_meta( + uuid, + body.title.as_deref(), + body.artist.as_deref(), + body.original_key.as_deref(), + ) + .await + .map(Json) + .map_err(|e| match e { + domain::RepositoryError::NotFound => + (StatusCode::NOT_FOUND, Json(ErrorResponse { error: "Not found".into() })), + e => (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e.to_string() })), + }) +} +``` + +- [ ] **Build** + +```bash +cd /mnt/drive/dev/pocket-chords && cargo build --workspace 2>&1 | tail -5 +``` + +Expected: clean. + +- [ ] **Commit** + +```bash +cd /mnt/drive/dev/pocket-chords +git add crates/ +git commit -m "feat: add update_meta to SongRepositoryPort and PATCH /songs/{id}" +``` + +--- + +## Task 3: Frontend layout shell and bottom nav + +**Files:** +- Modify: `app/app/routes.ts` +- Create: `app/app/routes/layout.tsx` +- Create: `app/app/components/bottom-nav.tsx` + +- [ ] **Check if DropdownMenu and AlertDialog are installed** + +```bash +ls /mnt/drive/dev/pocket-chords/app/app/components/ui/ | grep -E "dropdown|alert-dialog" +``` + +If missing, install: +```bash +cd /mnt/drive/dev/pocket-chords/app && npx shadcn add dropdown-menu alert-dialog 2>&1 +``` + +- [ ] **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; +``` + +- [ ] **Create `app/app/components/bottom-nav.tsx`** + +```tsx +import { NavLink } from "react-router"; +import { Music } from "lucide-react"; +import { cn } from "~/lib/utils"; + +export function BottomNav() { + return ( + + ); +} +``` + +- [ ] **Create `app/app/routes/layout.tsx`** + +```tsx +import { Outlet } from "react-router"; +import { Toaster } from "sonner"; +import { BottomNav } from "~/components/bottom-nav"; + +export default function Layout() { + return ( +
+
+ +
+ + +
+ ); +} +``` + +- [ ] **Typecheck** + +```bash +cd /mnt/drive/dev/pocket-chords/app && npm run typecheck 2>&1 +``` + +- [ ] **Run typegen if needed** (if `+types/layout` errors): + +```bash +cd /mnt/drive/dev/pocket-chords/app && npx react-router typegen 2>&1 +``` + +- [ ] **Commit** + +```bash +cd /mnt/drive/dev/pocket-chords +git add app/app/routes.ts app/app/routes/layout.tsx app/app/components/bottom-nav.tsx +git commit -m "feat(app): add layout shell with bottom nav and Toaster" +``` + +--- + +## Task 4: Live server-side search + +**Files:** +- Modify: `app/app/lib/api.ts` +- Modify: `app/app/lib/types.ts` +- Modify: `app/app/routes/home.tsx` + +- [ ] **Update `listSongs` in `app/app/lib/api.ts`** to accept optional query: + +```ts +export async function listSongs(q = ""): Promise { + const url = q.trim() + ? `${getApiBase()}/songs?q=${encodeURIComponent(q.trim())}` + : `${getApiBase()}/songs`; + const res = await fetch(url); + if (!res.ok) throw new Error(`Failed to load songs: ${res.status}`); + return res.json(); +} +``` + +- [ ] **Add `UpdateSongRequest` to `app/app/lib/types.ts`**: + +```ts +export interface UpdateSongRequest { + title?: string; + artist?: string; + original_key?: string; +} +``` + +- [ ] **Add `updateSong` to `app/app/lib/api.ts`**: + +```ts +export async function updateSong(id: string, patch: UpdateSongRequest): Promise { + const res = await fetch(`${getApiBase()}/songs/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error((data as { error?: string }).error ?? `HTTP ${res.status}`); + } + return res.json(); +} +``` + +Add `import type { Song, SongSummary, StoredSong, UpdateSongRequest } from "./types";` to the top of `api.ts`. + +- [ ] **Rewrite `app/app/routes/home.tsx`** + +```tsx +import { useCallback, useEffect, useRef, useState } from "react"; +import { useNavigate, useSearchParams, useRevalidator } from "react-router"; +import type { Route } from "./+types/home"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { Card, CardContent } from "~/components/ui/card"; +import { Plus } from "lucide-react"; +import { SongCard } from "~/components/song-card"; +import { AddSongSheet } from "~/components/add-song-sheet"; +import { listSongs } from "~/lib/api"; +import type { SongSummary } from "~/lib/types"; + +export function meta({}: Route.MetaArgs) { + return [ + { title: "PocketChords" }, + { name: "description", content: "Your personal chord chart library" }, + ]; +} + +export async function loader({ request }: Route.LoaderArgs) { + const q = new URL(request.url).searchParams.get("q") ?? ""; + try { + const songs = await listSongs(q); + return { songs, q, error: false }; + } catch { + return { songs: [], q, error: true }; + } +} + +export default function Home({ loaderData }: Route.ComponentProps) { + const { songs, q: initialQ, error } = loaderData; + const [searchParams, setSearchParams] = useSearchParams(); + const [sheetOpen, setSheetOpen] = useState(false); + const [localSongs, setLocalSongs] = useState([]); + const revalidator = useRevalidator(); + + // Input value tracks immediately; URL updates are debounced + const [inputValue, setInputValue] = useState(initialQ); + const debounceRef = useRef | null>(null); + + const handleSearch = useCallback((value: string) => { + setInputValue(value); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + setSearchParams(value.trim() ? { q: value.trim() } : {}, { replace: true }); + }, 300); + }, [setSearchParams]); + + // Clear local songs when search changes (they may not match) + useEffect(() => { setLocalSongs([]); }, [initialQ]); + + const allSongs = [...songs, ...localSongs]; + + return ( +
+ {/* Header */} +
+

PocketChords

+ +
+ + {/* Search */} +
+ handleSearch(e.target.value)} + className="w-full" + /> +
+ + {/* Error state */} + {error && ( +
+

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

+ +
+ )} + + {/* Grid */} +
+ {!error && allSongs.length === 0 && ( +

+ {initialQ ? "No songs match your search." : "No songs yet. Tap Add to get started."} +

+ )} +
+ {allSongs.map((song) => ( + + ))} + setSheetOpen(true)} + > + + + + +
+
+ + setLocalSongs((prev) => [...prev, summary])} + /> +
+ ); +} +``` + +- [ ] **Typecheck** + +```bash +cd /mnt/drive/dev/pocket-chords/app && npm run typecheck 2>&1 +``` + +- [ ] **Commit** + +```bash +cd /mnt/drive/dev/pocket-chords +git add app/app/lib/api.ts app/app/lib/types.ts app/app/routes/home.tsx +git commit -m "feat(app): live server-side search with 300ms debounce" +``` + +--- + +## Task 5: Song management — edit and delete + +**Files:** +- Create: `app/app/components/edit-song-sheet.tsx` +- Create: `app/app/components/delete-song-dialog.tsx` +- Modify: `app/app/components/transpose-bar.tsx` +- Modify: `app/app/routes/songs.$id.tsx` + +- [ ] **Create `app/app/components/edit-song-sheet.tsx`** + +```tsx +import { useState } from "react"; +import { + Sheet, SheetContent, SheetHeader, SheetTitle, +} from "~/components/ui/sheet"; +import { Input } from "~/components/ui/input"; +import { Button } from "~/components/ui/button"; +import { toast } from "sonner"; +import { updateSong } from "~/lib/api"; +import type { SongMeta, SongSummary } from "~/lib/types"; + +interface Props { + id: string; + meta: SongMeta; + open: boolean; + onOpenChange: (open: boolean) => void; + onUpdated: (summary: SongSummary) => void; +} + +export function EditSongSheet({ id, meta, open, onOpenChange, onUpdated }: Props) { + const [title, setTitle] = useState(meta.title); + const [artist, setArtist] = useState(meta.artist); + const [key, setKey] = useState(meta.original_key ?? ""); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + try { + const updated = await updateSong(id, { + title: title.trim() || undefined, + artist: artist.trim() || undefined, + original_key: key.trim() || undefined, + }); + onUpdated(updated); + onOpenChange(false); + } catch (err) { + toast.error("Failed to save changes", { + description: err instanceof Error ? err.message : undefined, + }); + } finally { + setLoading(false); + } + } + + return ( + + + + Edit Song + +
+
+ + setTitle(e.target.value)} disabled={loading} /> +
+
+ + setArtist(e.target.value)} disabled={loading} /> +
+
+ + setKey(e.target.value)} + placeholder="e.g. Em, G, Bb" + disabled={loading} + /> +
+
+ + +
+
+
+
+ ); +} +``` + +- [ ] **Create `app/app/components/delete-song-dialog.tsx`** + +```tsx +import { + AlertDialog, AlertDialogAction, AlertDialogCancel, + AlertDialogContent, AlertDialogDescription, AlertDialogFooter, + AlertDialogHeader, AlertDialogTitle, +} from "~/components/ui/alert-dialog"; +import { toast } from "sonner"; +import { deleteSong } from "~/lib/api"; +import { useNavigate } from "react-router"; +import { useState } from "react"; + +interface Props { + id: string; + title: string; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function DeleteSongDialog({ id, title, open, onOpenChange }: Props) { + const navigate = useNavigate(); + const [loading, setLoading] = useState(false); + + async function handleDelete() { + setLoading(true); + try { + await deleteSong(id); + navigate("/"); + } catch { + toast.error("Failed to delete song"); + setLoading(false); + onOpenChange(false); + } + } + + return ( + + + + Delete "{title}"? + + This cannot be undone. The song will be permanently removed. + + + + Cancel + + {loading ? "Deleting..." : "Delete"} + + + + + ); +} +``` + +- [ ] **Update `app/app/components/transpose-bar.tsx`** — add `onEdit`/`onDelete` props and DropdownMenu: + +Replace entire file: +```tsx +import { useState } from "react"; +import { Button } from "~/components/ui/button"; +import { + DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, +} from "~/components/ui/dropdown-menu"; +import { ChevronUp, ChevronDown, Minus, Plus, MoreHorizontal, Pencil, Trash2 } from "lucide-react"; +import type { SongMeta } from "~/lib/types"; + +interface Props { + meta: SongMeta; + offset: number; + onOffsetChange: (offset: number) => void; + onEdit?: () => void; + onDelete?: () => void; +} + +export function TransposeBar({ meta, offset, onOffsetChange, onEdit, onDelete }: Props) { + const [expanded, setExpanded] = useState(true); + + const label = offset === 0 ? "±0" : offset > 0 ? `+${offset}` : `${offset}`; + + const menuButton = (onEdit || onDelete) ? ( + + + + + + {onEdit && ( + + + Edit + + )} + {onDelete && ( + + + Delete + + )} + + + ) : null; + + if (!expanded) { + return ( +
+ {meta.title} +
+ {menuButton} + +
+
+ ); + } + + return ( +
+
+
+ {meta.title} + {meta.artist} +
+
+ {menuButton} + +
+
+ +
+
+ {meta.original_key && Key: {meta.original_key}} + {meta.capo != null && Capo: {meta.capo}} + {meta.tuning && {meta.tuning}} +
+
+ + {label} + +
+
+
+ ); +} +``` + +- [ ] **Update `app/app/routes/songs.$id.tsx`** + +```tsx +import { useState } from "react"; +import { data, Link } from "react-router"; +import type { Route } from "./+types/songs.$id"; +import { TransposeBar } from "~/components/transpose-bar"; +import { ChordChart } from "~/components/chord-chart"; +import { EditSongSheet } from "~/components/edit-song-sheet"; +import { DeleteSongDialog } from "~/components/delete-song-dialog"; +import { transposeSong } from "~/lib/transpose"; +import { getSong } from "~/lib/api"; +import type { Song, SongSummary } from "~/lib/types"; + +export function meta({ data }: Route.MetaArgs) { + if (!data?.song) return [{ title: "PocketChords" }]; + return [ + { title: `${data.song.meta.title} — PocketChords` }, + { name: "description", content: data.song.meta.artist }, + ]; +} + +export async function loader({ params }: Route.LoaderArgs) { + const id = params.id ?? ""; + try { + const song = await getSong(id); + if (!song) throw data("Song not found", { status: 404 }); + return { song, id }; + } catch (err: any) { + if (err?.status === 404) throw err; + return { song: null as unknown as Song, id }; + } +} + +export default function SongDetail({ loaderData }: Route.ComponentProps) { + const { song: initialSong, id } = loaderData; + const [song, setSong] = useState(initialSong ?? null); + const [offset, setOffset] = useState(0); + const [editOpen, setEditOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + + if (!song) { + return ( +
+

Song not found or unavailable.

+ + ← Back to library + +
+ ); + } + + const displayed = transposeSong(song, offset); + + function handleUpdated(summary: SongSummary) { + setSong((prev) => prev ? { ...prev, meta: summary.meta } : prev); + } + + return ( +
+ setEditOpen(true)} + onDelete={() => setDeleteOpen(true)} + /> +
+ +
+ + +
+ ); +} +``` + +- [ ] **Typecheck** + +```bash +cd /mnt/drive/dev/pocket-chords/app && npm run typecheck 2>&1 +``` + +- [ ] **Commit** + +```bash +cd /mnt/drive/dev/pocket-chords +git add app/app/components/ app/app/routes/songs.\$id.tsx +git commit -m "feat(app): add song edit and delete with dropdown menu" +``` + +--- + +## Task 6: Verification + +- [ ] **Start API** + +```bash +cd /mnt/drive/dev/pocket-chords && DATABASE_URL=sqlite://./pocket-chords.db cargo run -p api & +sleep 2 +``` + +- [ ] **Start frontend** + +```bash +cd /mnt/drive/dev/pocket-chords/app && npm run dev & +sleep 3 +``` + +- [ ] **Verify search** + +Open `http://localhost:5173/`. Type in search bar — requests should fire 300ms after typing stops (check browser network tab). Empty query returns full list. + +- [ ] **Verify nav** + +Bottom nav shows Library tab. Active on `/`, inactive on song detail. Tapping it from a song detail navigates to `/`. + +- [ ] **Verify edit** + +Open a song → tap `⋯` → Edit → change title → Save. Header updates immediately. Refresh — change persists. + +- [ ] **Verify delete** + +Open a song → tap `⋯` → Delete → confirm → navigates to library, song gone. + +- [ ] **Verify error state** + +Stop the API (`kill %1`). Reload library page — "Couldn't load your songs" inline with Retry button. Try navigating to a song URL directly — "Song not found or unavailable" with back link. + +- [ ] **Final checks** + +```bash +cd /mnt/drive/dev/pocket-chords && cargo test --workspace 2>&1 | tail -5 +cd /mnt/drive/dev/pocket-chords/app && npm run typecheck 2>&1 +``` + +Both clean. + +- [ ] **Stop dev servers** + +```bash +kill %1 %2 2>/dev/null; true +```