Files
pocket-chords/docs/superpowers/plans/2026-04-08-search-nav-management.md

34 KiB

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 <Outlet> + <BottomNav> + <Toaster>
  • 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.tslistSongs(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):

#[async_trait]
pub trait SongSearchPort: Send + Sync {
    async fn search(&self, query: &str) -> Result<Vec<SongSummary>, RepositoryError>;
}
  • Re-export SongSearchPort in crates/domain/src/lib.rs:
pub use ports::{FetchError, ParseError, RepositoryError, SongRepositoryPort, SongSearchPort, TabFetcherPort, TabParserPort, TabSource};
  • Add SongSearchService to crates/common/src/lib.rs (append after SongService):
use domain::SongSearchPort;

pub struct SongSearchService {
    search: Box<dyn SongSearchPort>,
}

impl SongSearchService {
    pub fn new(search: Box<dyn SongSearchPort>) -> Self {
        Self { search }
    }

    pub async fn search(&self, query: &str) -> Result<Vec<domain::SongSummary>, 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:

#[derive(Clone)]
pub struct SqliteSongRepository {
    pool: SqlitePool,
}

Append at the bottom of the file:

use domain::SongSearchPort;

#[async_trait]
impl SongSearchPort for SqliteSongRepository {
    async fn search(&self, query: &str) -> Result<Vec<SongSummary>, 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<String> = 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:
pub struct AppState {
    pub fetcher: Box<dyn TabFetcherPort>,
    pub parser: Box<dyn TabParserPort>,
    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:

use axum::extract::Query;
use serde::Deserialize;

Add struct before list_songs:

#[derive(Deserialize)]
pub struct ListQuery {
    pub q: Option<String>,
}

Replace list_songs:

pub async fn list_songs(
    State(state): State<Arc<AppState>>,
    Query(params): Query<ListQuery>,
) -> Result<Json<Vec<domain::SongSummary>>, (StatusCode, Json<ErrorResponse>)> {
    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:
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
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
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
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:

#[async_trait]
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>;
}
  • Add update_meta to SongService in crates/common/src/lib.rs:
pub async fn update_meta(
    &self,
    id: Uuid,
    title: Option<&str>,
    artist: Option<&str>,
    original_key: Option<&str>,
) -> Result<domain::SongSummary, domain::RepositoryError> {
    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:

async fn update_meta(
    &self,
    id: Uuid,
    title: Option<&str>,
    artist: Option<&str>,
    original_key: Option<&str>,
) -> Result<SongSummary, RepositoryError> {
    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<String> = 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:

#[derive(serde::Deserialize)]
pub struct UpdateSongRequest {
    pub title: Option<String>,
    pub artist: Option<String>,
    pub original_key: Option<String>,
}

pub async fn update_song(
    State(state): State<Arc<AppState>>,
    Path(id): Path<String>,
    Json(body): Json<UpdateSongRequest>,
) -> Result<Json<domain::SongSummary>, (StatusCode, Json<ErrorResponse>)> {
    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
cd /mnt/drive/dev/pocket-chords && cargo build --workspace 2>&1 | tail -5

Expected: clean.

  • Commit
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

ls /mnt/drive/dev/pocket-chords/app/app/components/ui/ | grep -E "dropdown|alert-dialog"

If missing, install:

cd /mnt/drive/dev/pocket-chords/app && npx shadcn add dropdown-menu alert-dialog 2>&1
  • 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;
  • Create app/app/components/bottom-nav.tsx
import { NavLink } from "react-router";
import { Music } from "lucide-react";
import { cn } from "~/lib/utils";

export function BottomNav() {
  return (
    <nav className="border-t bg-background shrink-0">
      <div className="max-w-lg mx-auto flex">
        <NavLink
          to="/"
          end
          className={({ isActive }) =>
            cn(
              "flex flex-col items-center gap-0.5 flex-1 py-2 text-xs transition-colors",
              isActive
                ? "text-primary"
                : "text-muted-foreground hover:text-foreground"
            )
          }
        >
          <Music className="w-5 h-5" />
          <span>Library</span>
        </NavLink>
      </div>
    </nav>
  );
}
  • Create app/app/routes/layout.tsx
import { Outlet } from "react-router";
import { Toaster } from "sonner";
import { BottomNav } from "~/components/bottom-nav";

export default function Layout() {
  return (
    <div className="flex flex-col h-dvh">
      <div className="flex-1 overflow-hidden">
        <Outlet />
      </div>
      <BottomNav />
      <Toaster position="top-center" richColors />
    </div>
  );
}
  • Typecheck
cd /mnt/drive/dev/pocket-chords/app && npm run typecheck 2>&1
  • Run typegen if needed (if +types/layout errors):
cd /mnt/drive/dev/pocket-chords/app && npx react-router typegen 2>&1
  • Commit
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"

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:

export async function listSongs(q = ""): Promise<SongSummary[]> {
  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:
export interface UpdateSongRequest {
  title?: string;
  artist?: string;
  original_key?: string;
}
  • Add updateSong to app/app/lib/api.ts:
export async function updateSong(id: string, patch: UpdateSongRequest): Promise<SongSummary> {
  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
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<SongSummary[]>([]);
  const revalidator = useRevalidator();

  // Input value tracks immediately; URL updates are debounced
  const [inputValue, setInputValue] = useState(initialQ);
  const debounceRef = useRef<ReturnType<typeof setTimeout> | 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 (
    <div className="flex flex-col h-full max-w-lg mx-auto">
      {/* Header */}
      <div className="flex items-center justify-between px-4 pt-4 pb-2">
        <h1 className="text-lg font-bold">PocketChords</h1>
        <Button size="sm" onClick={() => setSheetOpen(true)}>
          <Plus className="w-4 h-4 mr-1" />
          Add
        </Button>
      </div>

      {/* Search */}
      <div className="px-4 pb-3">
        <Input
          placeholder="Search songs..."
          value={inputValue}
          onChange={(e) => handleSearch(e.target.value)}
          className="w-full"
        />
      </div>

      {/* Error state */}
      {error && (
        <div className="flex flex-col items-center gap-3 pt-8 pb-4 px-6 text-center">
          <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>
      )}

      {/* Grid */}
      <div className="flex-1 overflow-y-auto px-4 pb-4">
        {!error && allSongs.length === 0 && (
          <p className="text-sm text-muted-foreground text-center pt-8 pb-4">
            {initialQ ? "No songs match your search." : "No songs yet. Tap Add to get started."}
          </p>
        )}
        <div className="grid grid-cols-2 gap-3">
          {allSongs.map((song) => (
            <SongCard key={song.id} song={song} />
          ))}
          <Card
            className="h-full border-dashed cursor-pointer hover:bg-accent transition-colors"
            onClick={() => setSheetOpen(true)}
          >
            <CardContent className="p-3 flex items-center justify-center h-full min-h-[80px]">
              <Plus className="w-6 h-6 text-muted-foreground" />
            </CardContent>
          </Card>
        </div>
      </div>

      <AddSongSheet
        open={sheetOpen}
        onOpenChange={setSheetOpen}
        onSongAdded={(summary) => setLocalSongs((prev) => [...prev, summary])}
      />
    </div>
  );
}
  • Typecheck
cd /mnt/drive/dev/pocket-chords/app && npm run typecheck 2>&1
  • Commit
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

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 (
    <Sheet open={open} onOpenChange={onOpenChange}>
      <SheetContent side="bottom" className="rounded-t-xl">
        <SheetHeader className="mb-4">
          <SheetTitle>Edit Song</SheetTitle>
        </SheetHeader>
        <form onSubmit={handleSubmit} className="flex flex-col gap-3">
          <div className="flex flex-col gap-1">
            <label className="text-xs text-muted-foreground uppercase tracking-wide">Title</label>
            <Input value={title} onChange={(e) => setTitle(e.target.value)} disabled={loading} />
          </div>
          <div className="flex flex-col gap-1">
            <label className="text-xs text-muted-foreground uppercase tracking-wide">Artist</label>
            <Input value={artist} onChange={(e) => setArtist(e.target.value)} disabled={loading} />
          </div>
          <div className="flex flex-col gap-1">
            <label className="text-xs text-muted-foreground uppercase tracking-wide">Key</label>
            <Input
              value={key}
              onChange={(e) => setKey(e.target.value)}
              placeholder="e.g. Em, G, Bb"
              disabled={loading}
            />
          </div>
          <div className="flex gap-2 pt-1">
            <Button type="button" variant="outline" className="flex-1"
              onClick={() => onOpenChange(false)} disabled={loading}>
              Cancel
            </Button>
            <Button type="submit" className="flex-1" disabled={loading}>
              {loading ? "Saving..." : "Save"}
            </Button>
          </div>
        </form>
      </SheetContent>
    </Sheet>
  );
}
  • Create app/app/components/delete-song-dialog.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 (
    <AlertDialog open={open} onOpenChange={onOpenChange}>
      <AlertDialogContent>
        <AlertDialogHeader>
          <AlertDialogTitle>Delete "{title}"?</AlertDialogTitle>
          <AlertDialogDescription>
            This cannot be undone. The song will be permanently removed.
          </AlertDialogDescription>
        </AlertDialogHeader>
        <AlertDialogFooter>
          <AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
          <AlertDialogAction
            onClick={handleDelete}
            disabled={loading}
            className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
          >
            {loading ? "Deleting..." : "Delete"}
          </AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  );
}
  • Update app/app/components/transpose-bar.tsx — add onEdit/onDelete props and DropdownMenu:

Replace entire file:

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) ? (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="ghost" size="icon" className="h-7 w-7 shrink-0">
          <MoreHorizontal className="w-4 h-4" />
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        {onEdit && (
          <DropdownMenuItem onClick={onEdit}>
            <Pencil className="w-4 h-4 mr-2" />
            Edit
          </DropdownMenuItem>
        )}
        {onDelete && (
          <DropdownMenuItem onClick={onDelete} className="text-destructive focus:text-destructive">
            <Trash2 className="w-4 h-4 mr-2" />
            Delete
          </DropdownMenuItem>
        )}
      </DropdownMenuContent>
    </DropdownMenu>
  ) : null;

  if (!expanded) {
    return (
      <div className="flex items-center justify-between px-4 py-2 border-b bg-background sticky top-0">
        <span className="text-sm font-semibold truncate">{meta.title}</span>
        <div className="flex items-center gap-1">
          {menuButton}
          <Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setExpanded(true)}>
            <ChevronDown className="w-4 h-4" />
          </Button>
        </div>
      </div>
    );
  }

  return (
    <div className="border-b bg-background sticky top-0 px-4 py-3 flex flex-col gap-2">
      <div className="flex items-start justify-between">
        <div className="flex flex-col">
          <span className="font-bold text-base">{meta.title}</span>
          <span className="text-sm text-muted-foreground">{meta.artist}</span>
        </div>
        <div className="flex items-center gap-1">
          {menuButton}
          <Button variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={() => setExpanded(false)}>
            <ChevronUp className="w-4 h-4" />
          </Button>
        </div>
      </div>

      <div className="flex items-center justify-between">
        <div className="flex gap-3 text-xs text-muted-foreground">
          {meta.original_key && <span>Key: {meta.original_key}</span>}
          {meta.capo != null && <span>Capo: {meta.capo}</span>}
          {meta.tuning && <span>{meta.tuning}</span>}
        </div>
        <div className="flex items-center gap-2">
          <Button variant="ghost" size="icon" className="h-8 w-8"
            onClick={() => onOffsetChange(Math.max(-11, offset - 1))}>
            <Minus className="w-4 h-4" />
          </Button>
          <span className="w-8 text-center text-sm font-mono font-semibold">{label}</span>
          <Button variant="ghost" size="icon" className="h-8 w-8"
            onClick={() => onOffsetChange(Math.min(11, offset + 1))}>
            <Plus className="w-4 h-4" />
          </Button>
        </div>
      </div>
    </div>
  );
}
  • Update app/app/routes/songs.$id.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<Song | null>(initialSong ?? null);
  const [offset, setOffset] = useState(0);
  const [editOpen, setEditOpen] = useState(false);
  const [deleteOpen, setDeleteOpen] = useState(false);

  if (!song) {
    return (
      <div className="flex flex-col items-center justify-center h-full gap-4">
        <p className="text-muted-foreground text-sm">Song not found or unavailable.</p>
        <Link to="/" className="text-sm text-primary underline-offset-4 hover:underline">
           Back to library
        </Link>
      </div>
    );
  }

  const displayed = transposeSong(song, offset);

  function handleUpdated(summary: SongSummary) {
    setSong((prev) => prev ? { ...prev, meta: summary.meta } : prev);
  }

  return (
    <div className="flex flex-col h-full max-w-lg mx-auto">
      <TransposeBar
        meta={song.meta}
        offset={offset}
        onOffsetChange={setOffset}
        onEdit={() => setEditOpen(true)}
        onDelete={() => setDeleteOpen(true)}
      />
      <div className="flex-1 overflow-y-auto">
        <ChordChart sections={displayed.sections} />
      </div>
      <EditSongSheet
        id={id}
        meta={song.meta}
        open={editOpen}
        onOpenChange={setEditOpen}
        onUpdated={handleUpdated}
      />
      <DeleteSongDialog
        id={id}
        title={song.meta.title}
        open={deleteOpen}
        onOpenChange={setDeleteOpen}
      />
    </div>
  );
}
  • Typecheck
cd /mnt/drive/dev/pocket-chords/app && npm run typecheck 2>&1
  • Commit
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
cd /mnt/drive/dev/pocket-chords && DATABASE_URL=sqlite://./pocket-chords.db cargo run -p api &
sleep 2
  • Start frontend
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
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
kill %1 %2 2>/dev/null; true