This commit is contained in:
@@ -1 +1 @@
|
||||
VITE_API_URL=http://localhost:8000
|
||||
VITE_API_URL=/api
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,353 +0,0 @@
|
||||
# Search, Nav, Management & Error States — Design Spec
|
||||
|
||||
**Date:** 2026-04-08
|
||||
**Scope:** Backend search service, frontend nav shell, song management (edit + delete), error states.
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
PocketChords has working persistence and a functional chord viewer. This iteration makes the app feel polished and complete: server-side search, consistent navigation, song management, and proper error handling.
|
||||
|
||||
---
|
||||
|
||||
## 1. Backend: Search Service
|
||||
|
||||
### Architecture
|
||||
|
||||
A dedicated `SongSearchService` with its own port — fully decoupled from `SongService`. Search and CRUD are independently swappable.
|
||||
|
||||
```
|
||||
GET /songs?q=… → SongSearchService → Box<dyn SongSearchPort> → SqliteSongRepository
|
||||
GET /songs → SongService → Box<dyn SongRepositoryPort> → SqliteSongRepository
|
||||
```
|
||||
|
||||
`SqliteSongRepository` implements both ports — one struct, two trait impls.
|
||||
|
||||
### New domain port (`crates/domain/src/ports.rs`)
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait SongSearchPort: Send + Sync {
|
||||
async fn search(&self, query: &str) -> Result<Vec<SongSummary>, RepositoryError>;
|
||||
}
|
||||
```
|
||||
|
||||
### `SqliteSongRepository` search impl (`crates/infrastructure/persistence/src/lib.rs`)
|
||||
|
||||
SQLite `LIKE` query on title and artist columns:
|
||||
```sql
|
||||
SELECT id, title, artist, original_key, preview_chords
|
||||
FROM songs
|
||||
WHERE title LIKE ? OR artist LIKE ?
|
||||
ORDER BY created_at DESC
|
||||
```
|
||||
Bind parameter: `format!("%{}%", query)` for both.
|
||||
|
||||
### New `SongSearchService` (`crates/common/src/lib.rs`)
|
||||
|
||||
```rust
|
||||
pub struct SongSearchService {
|
||||
search: Box<dyn SongSearchPort>,
|
||||
}
|
||||
impl SongSearchService {
|
||||
pub fn new(search: Box<dyn SongSearchPort>) -> Self
|
||||
pub async fn search(&self, query: &str) -> Result<Vec<SongSummary>, RepositoryError>
|
||||
}
|
||||
```
|
||||
|
||||
### `AppState` update (`crates/api/src/routes/tabs.rs`)
|
||||
|
||||
```rust
|
||||
pub struct AppState {
|
||||
pub fetcher: Box<dyn TabFetcherPort>,
|
||||
pub parser: Box<dyn TabParserPort>,
|
||||
pub songs: SongService,
|
||||
pub search: SongSearchService,
|
||||
}
|
||||
```
|
||||
|
||||
### API endpoint update (`crates/api/src/routes/songs.rs`)
|
||||
|
||||
`GET /songs` — branches on presence of `q` query param:
|
||||
```rust
|
||||
#[derive(Deserialize)]
|
||||
pub struct ListQuery { pub q: Option<String> }
|
||||
|
||||
pub async fn list_songs(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(params): Query<ListQuery>,
|
||||
) -> Result<Json<Vec<SongSummary>>, ...> {
|
||||
if let Some(q) = params.q.filter(|s| !s.is_empty()) {
|
||||
state.search.search(&q).await ...
|
||||
} else {
|
||||
state.songs.list().await ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `main.rs` wiring
|
||||
|
||||
Use a single `Arc<SqliteSongRepository>` shared between both services — avoids two connection pools:
|
||||
|
||||
```rust
|
||||
use std::sync::Arc;
|
||||
let repo = Arc::new(SqliteRepositoryFactory::create(&database_url).await?);
|
||||
let songs = SongService::new(Box::new(Arc::clone(&repo)));
|
||||
let search = SongSearchService::new(Box::new(Arc::clone(&repo)));
|
||||
```
|
||||
|
||||
Requires `SqliteSongRepository` to implement both ports, and `Arc<SqliteSongRepository>` to implement them via blanket delegation. In practice: implement the traits on `Arc<SqliteSongRepository>` directly, or on `SqliteSongRepository` and add `#[async_trait] impl SongRepositoryPort for Arc<SqliteSongRepository> { ... }` forwarding impls.
|
||||
|
||||
---
|
||||
|
||||
## 2. Backend: Edit endpoint
|
||||
|
||||
### New endpoint
|
||||
|
||||
`PATCH /songs/:id` — updates mutable metadata fields only.
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{ "title": "New Title", "artist": "New Artist", "original_key": "Am" }
|
||||
```
|
||||
All fields optional. Only provided fields are updated.
|
||||
|
||||
**Response:** `200 OK` with updated `SongSummary`.
|
||||
|
||||
### Domain port update
|
||||
|
||||
```rust
|
||||
pub trait SongRepositoryPort: Send + Sync {
|
||||
async fn save(&self, song: &Song) -> Result<StoredSong, RepositoryError>;
|
||||
async fn list(&self) -> Result<Vec<SongSummary>, RepositoryError>;
|
||||
async fn get(&self, id: Uuid) -> Result<Option<Song>, RepositoryError>;
|
||||
async fn delete(&self, id: Uuid) -> Result<(), RepositoryError>;
|
||||
async fn update_meta(&self, id: Uuid, title: Option<&str>, artist: Option<&str>, original_key: Option<&str>) -> Result<SongSummary, RepositoryError>;
|
||||
}
|
||||
```
|
||||
|
||||
### `SongService` gains `update_meta`
|
||||
|
||||
Delegates to repo. Also updates `body` JSON so the full Song stays in sync:
|
||||
```sql
|
||||
UPDATE songs SET title = COALESCE(?, title), artist = COALESCE(?, artist),
|
||||
original_key = COALESCE(?, original_key), body = ?
|
||||
WHERE id = ?
|
||||
```
|
||||
Deserializes `body`, patches `meta`, re-serializes, writes back.
|
||||
|
||||
### New handler `update_song` in `songs.rs`
|
||||
|
||||
```rust
|
||||
pub async fn update_song(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<UpdateSongRequest>,
|
||||
) -> Result<Json<SongSummary>, (StatusCode, Json<ErrorResponse>)>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Frontend: Layout & Nav
|
||||
|
||||
### New files
|
||||
|
||||
- `app/app/routes/layout.tsx` — parent route shell with bottom tab bar
|
||||
- `app/app/components/bottom-nav.tsx` — single Library tab
|
||||
|
||||
### Route config update (`app/app/routes.ts`)
|
||||
|
||||
```ts
|
||||
import { type RouteConfig, index, layout, route } from "@react-router/dev/routes";
|
||||
|
||||
export default [
|
||||
layout("routes/layout.tsx", [
|
||||
index("routes/home.tsx"),
|
||||
route("songs/:id", "routes/songs.$id.tsx"),
|
||||
]),
|
||||
] satisfies RouteConfig;
|
||||
```
|
||||
|
||||
### `layout.tsx`
|
||||
|
||||
```tsx
|
||||
export default function Layout() {
|
||||
return (
|
||||
<div className="flex flex-col h-dvh">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<Outlet />
|
||||
</div>
|
||||
<BottomNav />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### `bottom-nav.tsx`
|
||||
|
||||
Single tab: Library icon + label, links to `/`, highlights when active (`useLocation`).
|
||||
|
||||
```tsx
|
||||
<nav className="border-t bg-background">
|
||||
<NavLink to="/" className={({ isActive }) => ...}>
|
||||
<Music className="w-5 h-5" />
|
||||
<span className="text-xs">Library</span>
|
||||
</NavLink>
|
||||
</nav>
|
||||
```
|
||||
|
||||
Uses `NavLink` from react-router for active state styling.
|
||||
|
||||
---
|
||||
|
||||
## 4. Frontend: Live Search
|
||||
|
||||
### `home.tsx` changes
|
||||
|
||||
- Remove `useState(query)` client-side filter
|
||||
- Add `useSearchParams` hook — search term lives in URL (`?q=…`)
|
||||
- Debounce input changes (300ms) before updating URL param
|
||||
- Loader reads `q` from `request.url` and calls `listSongs(q)` or `listSongs()`
|
||||
|
||||
```ts
|
||||
// loader
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const q = new URL(request.url).searchParams.get("q") ?? "";
|
||||
const songs = await listSongs(q);
|
||||
return { songs, q };
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// api.ts
|
||||
export async function listSongs(q = ""): Promise<SongSummary[]> {
|
||||
const url = q ? `${API_BASE}/songs?q=${encodeURIComponent(q)}` : `${API_BASE}/songs`;
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Component uses `useNavigate` + `useSearchParams` + debounced `setSearchParams`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Frontend: Song Management
|
||||
|
||||
### `TransposeBar` update
|
||||
|
||||
Add `onEdit` and `onDelete` prop callbacks. Add `DropdownMenu` (shadcn) triggered by a `MoreHorizontal` icon button in the header row.
|
||||
|
||||
Menu items:
|
||||
- **Edit** → calls `onEdit()`
|
||||
- **Delete** → calls `onDelete()`
|
||||
|
||||
### New `EditSongSheet` component (`app/app/components/edit-song-sheet.tsx`)
|
||||
|
||||
Bottom `Sheet` with three inputs: Title, Artist, Key. Pre-filled from current `SongMeta`. Submit calls `updateSong(id, { title, artist, original_key })` → updates `song.meta` in component state → closes sheet.
|
||||
|
||||
### New `DeleteSongDialog` component (`app/app/components/delete-song-dialog.tsx`)
|
||||
|
||||
`AlertDialog` (shadcn): "Are you sure? This cannot be undone." Confirm → `deleteSong(id)` → navigate to `/`.
|
||||
|
||||
### `songs.$id.tsx` changes
|
||||
|
||||
- Import `EditSongSheet`, `DeleteSongDialog`
|
||||
- Track `editOpen`, `deleteOpen` state
|
||||
- Pass `onEdit`/`onDelete` to `TransposeBar`
|
||||
|
||||
### New `api.ts` helpers
|
||||
|
||||
```ts
|
||||
export async function updateSong(id: string, patch: {
|
||||
title?: string; artist?: string; original_key?: string;
|
||||
}): Promise<SongSummary>
|
||||
|
||||
export async function deleteSong(id: string): Promise<void> // already exists
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Frontend: Error States
|
||||
|
||||
### Library (`home.tsx`)
|
||||
|
||||
Loader catches API errors and returns `{ songs: [], error: true }`. Component shows inline error when `error` is true:
|
||||
|
||||
```tsx
|
||||
{loaderData.error && (
|
||||
<div className="flex flex-col items-center gap-3 pt-12 text-center px-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Couldn't load your songs. Is the API running?
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={() => revalidator.revalidate()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
Uses `useRevalidator` from react-router for the retry.
|
||||
|
||||
### Song detail (`songs.$id.tsx`)
|
||||
|
||||
`getSong` returns `null` on 404 or throws on network error. Loader returns `{ song: null }` on any failure. Component shows:
|
||||
|
||||
```tsx
|
||||
{!song && (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4">
|
||||
<p className="text-muted-foreground">Song not found or unavailable.</p>
|
||||
<Link to="/" className="text-sm text-primary">← Back to library</Link>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
### Transient errors (toasts)
|
||||
|
||||
`sonner` is already in the project. Import `toast` from `sonner`. Fire on:
|
||||
- Add song failure: `toast.error("Failed to import song", { description: err.message })`
|
||||
- Delete failure: `toast.error("Failed to delete song")`
|
||||
- Edit failure: `toast.error("Failed to save changes")`
|
||||
|
||||
Add `<Toaster />` to `layout.tsx` (one location, covers all pages).
|
||||
|
||||
---
|
||||
|
||||
## New/Modified Files Summary
|
||||
|
||||
**Rust:**
|
||||
- `crates/domain/src/ports.rs` — add `SongSearchPort`, add `update_meta` to `SongRepositoryPort`
|
||||
- `crates/domain/src/lib.rs` — re-export `SongSearchPort`
|
||||
- `crates/infrastructure/persistence/src/lib.rs` — impl `SongSearchPort`, impl `update_meta`
|
||||
- `crates/common/src/lib.rs` — add `SongSearchService`, add `SongService::update_meta`
|
||||
- `crates/api/src/routes/tabs.rs` — add `search: SongSearchService` to `AppState`
|
||||
- `crates/api/src/routes/songs.rs` — update `list_songs` for `?q=`, add `update_song`
|
||||
- `crates/api/src/main.rs` — wire `SongSearchService`, add `PATCH /songs/{id}`
|
||||
|
||||
**Frontend:**
|
||||
- `app/app/routes.ts` — add layout route
|
||||
- `app/app/routes/layout.tsx` — new shell with `<Outlet>` + `<BottomNav>`
|
||||
- `app/app/components/bottom-nav.tsx` — new single-tab nav
|
||||
- `app/app/routes/home.tsx` — URL-based search params, loader uses `q`
|
||||
- `app/app/routes/songs.$id.tsx` — edit/delete integration, null error state
|
||||
- `app/app/components/transpose-bar.tsx` — add DropdownMenu with Edit/Delete
|
||||
- `app/app/components/edit-song-sheet.tsx` — new edit sheet
|
||||
- `app/app/components/delete-song-dialog.tsx` — new confirm dialog
|
||||
- `app/app/lib/api.ts` — update `listSongs(q?)`, add `updateSong`
|
||||
- `app/app/lib/types.ts` — add `UpdateSongRequest`
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
1. `cargo build --workspace` — clean
|
||||
2. `cargo test --workspace` — all pass
|
||||
3. `GET /songs?q=ocean` returns songs matching title/artist
|
||||
4. `PATCH /songs/:id` with `{ "title": "New" }` updates title, leaves rest unchanged
|
||||
5. Library search input debounces — network tab shows requests fire 300ms after typing stops
|
||||
6. Song detail `⋯` menu shows Edit and Delete
|
||||
7. Edit sheet pre-fills current values, saves successfully
|
||||
8. Delete dialog navigates back to library on confirm
|
||||
9. With API stopped: library shows "Couldn't load" inline + Retry; detail shows "← Back"
|
||||
10. Failed add/delete fires a sonner toast
|
||||
11. Bottom nav tab highlights on `/`, not highlighted on `/songs/:id`
|
||||
12. `npm run typecheck` — clean
|
||||
@@ -1,134 +0,0 @@
|
||||
# Chord Diagram Feature Design
|
||||
|
||||
**Date:** 2026-04-09
|
||||
|
||||
## Overview
|
||||
|
||||
A chord diagram feature for the song detail page that shows users how to play each chord on piano or guitar. The core component is dumb — it receives only a chord name string and renders the diagram. All music theory and voicing logic lives in a separate library layer.
|
||||
|
||||
## Architecture
|
||||
|
||||
Three cleanly separated layers:
|
||||
|
||||
```
|
||||
chord name string ("Cmaj7")
|
||||
│
|
||||
▼
|
||||
[theory layer] — tonal parses name → root + note set {C, E, G, B}
|
||||
│
|
||||
▼
|
||||
[voicing layer] — maps note set → renderable positions
|
||||
├── Piano: note set → highlight keys on 1-octave keyboard
|
||||
└── Guitar: chord quality + root → transpose moveable shape template
|
||||
│
|
||||
▼
|
||||
[render layer] — dumb components, no music theory knowledge
|
||||
├── <PianoKeys notes={["C","E","G","B"]} />
|
||||
└── <GuitarFretboard frets={[x,3,2,0,1,0]} baseFret={1} />
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
app/app/
|
||||
lib/
|
||||
chord-voicing.ts # theory layer: tonal → notes + guitar voicing
|
||||
guitar-voicings.ts # data: ~25 quality templates
|
||||
components/
|
||||
chord-diagram/
|
||||
piano-keys.tsx # dumb renderer: string[] of note names → keyboard
|
||||
guitar-fretboard.tsx # dumb renderer: frets[] + baseFret → fretboard grid
|
||||
chord-diagram.tsx # entry point: chord+instrument → voicing → renderer
|
||||
chord-grid.tsx # wrapped grid of ChordDiagram cards for all song chords
|
||||
```
|
||||
|
||||
## Component API
|
||||
|
||||
### `<ChordDiagram>`
|
||||
|
||||
```tsx
|
||||
<ChordDiagram chord="Cmaj7" instrument="piano" />
|
||||
<ChordDiagram chord="Am" instrument="guitar" />
|
||||
```
|
||||
|
||||
Renders nothing (graceful empty) if the chord cannot be parsed or has no voicing.
|
||||
|
||||
### `<ChordGrid>`
|
||||
|
||||
```tsx
|
||||
<ChordGrid chords={["Em7", "G", "Dsus4", "Am7"]} />
|
||||
```
|
||||
|
||||
Owns the `instrument` state (`"piano" | "guitar"`), persisted to `localStorage` as `chordDiagramInstrument`. Renders a global piano/guitar toggle and a `flex-wrap` grid of `<ChordDiagram>` cards.
|
||||
|
||||
## Diagram Styles
|
||||
|
||||
- **Piano:** dot notation — white keys with filled circles on pressed keys, black keys overlaid. 1 fixed octave shown (C to B); notes are matched by name regardless of octave.
|
||||
- **Guitar:** standard vertical fretboard — nut at top, 4 frets shown, dots on finger positions, O/X above strings for open/muted. Barre indicator where applicable.
|
||||
|
||||
## Theory Layer (`chord-voicing.ts`)
|
||||
|
||||
Uses `@tonaljs/tonal` (already in npm, tree-shakeable):
|
||||
|
||||
```ts
|
||||
export function getPianoNotes(chord: string): string[]
|
||||
// "Cmaj7" → ["C", "E", "G", "B"]
|
||||
// Returns [] if unparseable
|
||||
|
||||
export function getGuitarVoicing(chord: string): GuitarVoicing | null
|
||||
// "Am" → { frets: [0,0,2,2,1,0], baseFret: 1, barre: null }
|
||||
// Returns null if quality not in voicing map
|
||||
```
|
||||
|
||||
## Guitar Voicing Data (`guitar-voicings.ts`)
|
||||
|
||||
~25 moveable barre-chord templates keyed by `tonal` chord type name. Each template is a barre shape (no open strings) so it can be transposed by shifting `baseFret`. Two shape families are used: E-shapes (root on 6th string) and A-shapes (root on 5th string). `baseFret` is computed as the semitone distance from the template shape's root string pitch (E or A) to the target chord root.
|
||||
|
||||
```ts
|
||||
interface GuitarVoicingTemplate {
|
||||
frets: (number | null)[] // 6 strings; null = muted; fret numbers relative to baseFret
|
||||
baseFret: number // 1 in template; shifted when transposing to target root
|
||||
barre: number | null // fret (relative to baseFret) to draw barre, or null
|
||||
rootString: 'E' | 'A' // which string carries the root (determines transposition offset)
|
||||
}
|
||||
```
|
||||
|
||||
Quality names match `tonal`'s `Chord.get(name).type` output (e.g. `"major"`, `"minor"`, `"major seventh"`, `"dominant seventh"`, `"minor seventh"`, `"diminished"`, `"augmented"`, `"suspended fourth"`, `"suspended second"`, `"half-diminished"`, `"dominant seventh flat five"`, etc.). ~25 entries total.
|
||||
|
||||
If `tonal` returns a quality name not in the map, `getGuitarVoicing` returns `null` and the diagram renders a "no guitar voicing" placeholder.
|
||||
|
||||
## Layout & Integration
|
||||
|
||||
### Breakpoint
|
||||
|
||||
`lg` (Tailwind) divides mobile from desktop layout.
|
||||
|
||||
### Mobile
|
||||
|
||||
- Below `lg`: lyrics and diagrams in a single column.
|
||||
- **Inline popup:** tapping a chord name in `chord-chart.tsx` sets `activeChord` state in `songs.$id.tsx`. An inline `<ChordDiagram>` panel appears immediately below the tapped line. It closes when the scroll container fires a `scroll` event.
|
||||
- **Bottom grid:** `<ChordGrid>` rendered after `<ChordChart>` in the scroll column. Not sticky — scrolls with content.
|
||||
|
||||
### Desktop
|
||||
|
||||
- At `lg` and above: `songs.$id.tsx` switches to a two-column layout.
|
||||
- Left column: `<ChordChart>` (existing).
|
||||
- Right column: `<ChordGrid>` showing all unique chords in the song, wrapped. No inline popup on desktop (side column is always visible).
|
||||
|
||||
### Chord list source
|
||||
|
||||
`songs.$id.tsx` derives `uniqueChords: string[]` from `displayed.sections` — all unique chord names in order of first appearance, deduplicated. This list is passed to `<ChordGrid>` and also used to determine which chord names in `<ChordChart>` are tappable.
|
||||
|
||||
### Instrument toggle
|
||||
|
||||
Global piano/guitar toggle lives in `<ChordGrid>`. State persisted to `localStorage` as `chordDiagramInstrument`. Switching updates all visible diagrams at once.
|
||||
|
||||
## Error / Unknown Chord Handling
|
||||
|
||||
- `getPianoNotes` returns `[]` → `<PianoKeys>` renders with no dots highlighted and a subtle "?" label.
|
||||
- `getGuitarVoicing` returns `null` → `<GuitarFretboard>` renders an empty fretboard with a "no voicing" label.
|
||||
- Unparseable chord name (garbage string) → same fallback as above.
|
||||
|
||||
## Dependency
|
||||
|
||||
Add `tonal` to `app/package.json`. It is tree-shakeable; only chord parsing and note utilities will be bundled.
|
||||
Reference in New Issue
Block a user