1123 lines
34 KiB
Markdown
1123 lines
34 KiB
Markdown
# 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.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<Vec<SongSummary>, 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<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`:
|
|
```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<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`:
|
|
|
|
```rust
|
|
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:
|
|
```rust
|
|
use axum::extract::Query;
|
|
use serde::Deserialize;
|
|
```
|
|
|
|
Add struct before `list_songs`:
|
|
```rust
|
|
#[derive(Deserialize)]
|
|
pub struct ListQuery {
|
|
pub q: Option<String>,
|
|
}
|
|
```
|
|
|
|
Replace `list_songs`:
|
|
```rust
|
|
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`**:
|
|
|
|
```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<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`:
|
|
|
|
```rust
|
|
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:
|
|
```rust
|
|
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:
|
|
```rust
|
|
#[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**
|
|
|
|
```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 (
|
|
<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`**
|
|
|
|
```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**
|
|
|
|
```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<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`**:
|
|
|
|
```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<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`**
|
|
|
|
```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**
|
|
|
|
```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 (
|
|
<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`**
|
|
|
|
```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:
|
|
```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) ? (
|
|
<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`**
|
|
|
|
```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**
|
|
|
|
```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
|
|
```
|