Compare commits
18 Commits
f80ed4e88a
...
b1d778284c
| Author | SHA1 | Date | |
|---|---|---|---|
| b1d778284c | |||
| 808ce287a5 | |||
| 3605bef2b0 | |||
| dbb7cbd92f | |||
| ed669b2e3a | |||
| 2964475df9 | |||
| 544474c074 | |||
| e9c2f7a5e0 | |||
| e99b581a43 | |||
| ec7237e8c7 | |||
| 3d56b71aa9 | |||
| 5a4d0ef648 | |||
| 7653933f46 | |||
| 90df2b36f2 | |||
| ac65be1bb9 | |||
| aeb9dfff67 | |||
| bb99f5964a | |||
| df90e610ef |
22
.env.compose
Normal file
22
.env.compose
Normal file
@@ -0,0 +1,22 @@
|
||||
# Copy this file and fill in your values before deploying:
|
||||
# cp .env.compose .env.compose.local
|
||||
#
|
||||
# Then run:
|
||||
# docker compose --env-file .env.compose.local up -d --build
|
||||
|
||||
# ── Frontend ──────────────────────────────────────────────────────────────────
|
||||
# URL your browser (and the SSR server) uses to reach the API.
|
||||
# Baked into the JS bundle at build time — rebuild the app image when changing.
|
||||
# LAN example: http://192.168.1.100:8000
|
||||
# Reverse proxy: https://pocketchords.yourdomain.com/api
|
||||
VITE_API_URL=http://localhost:8000
|
||||
|
||||
# ── Backend ───────────────────────────────────────────────────────────────────
|
||||
# Comma-separated allowed CORS origins, or * for any.
|
||||
# Lock this down when exposing publicly: https://pocketchords.yourdomain.com
|
||||
CORS_ALLOWED_ORIGINS=*
|
||||
|
||||
# ── Ports (host-side) ─────────────────────────────────────────────────────────
|
||||
# Change if something else is already using these ports on the host.
|
||||
API_PORT=8000
|
||||
APP_PORT=3000
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
/target
|
||||
.env
|
||||
.superpowers/
|
||||
29
Dockerfile
Normal file
29
Dockerfile
Normal file
@@ -0,0 +1,29 @@
|
||||
FROM rust:1.92 AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
|
||||
# Build the release binary
|
||||
RUN cargo build --release -p api
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install OpenSSL, CA certs, and ffmpeg (provides ffprobe for local-files duration scanning)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libssl3 \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /app/target/release/api .
|
||||
|
||||
|
||||
# Create data directory for SQLite
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
ENV DATABASE_URL=sqlite:///app/data/pocket-chords.db
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["./api"]
|
||||
@@ -12,6 +12,9 @@ FROM node:20-alpine AS build-env
|
||||
COPY . /app/
|
||||
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
|
||||
WORKDIR /app
|
||||
# VITE_API_URL is baked into the bundle at build time (both SSR and client use it)
|
||||
ARG VITE_API_URL=http://localhost:8000
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine
|
||||
|
||||
@@ -1,27 +1,16 @@
|
||||
import type { LyricLine, Section } from "~/lib/types";
|
||||
|
||||
// Max characters per rendered line. 38 fits comfortably on a 375px phone
|
||||
// at 14px monospace (≈8.4px per char with padding).
|
||||
const MAX_WIDTH = 38;
|
||||
|
||||
interface Props {
|
||||
sections: Section[];
|
||||
fontSize?: 'sm' | 'base' | 'lg';
|
||||
}
|
||||
|
||||
function buildChordRow(chords: { offset: number; chord: string }[]): string {
|
||||
let row = "";
|
||||
for (const { offset, chord } of chords) {
|
||||
while (row.length < offset) row += " ";
|
||||
row += chord;
|
||||
}
|
||||
return row;
|
||||
onChordClick?: (chord: string) => void;
|
||||
}
|
||||
|
||||
/** Split one LyricLine into segments that each fit within maxWidth characters. */
|
||||
function segmentLine(line: LyricLine, maxWidth: number): LyricLine[] {
|
||||
const { text, chords } = line;
|
||||
|
||||
if (text.length <= maxWidth) return [line];
|
||||
|
||||
const segments: LyricLine[] = [];
|
||||
@@ -29,39 +18,61 @@ function segmentLine(line: LyricLine, maxWidth: number): LyricLine[] {
|
||||
|
||||
while (start < text.length) {
|
||||
let end = start + maxWidth;
|
||||
|
||||
if (end < text.length) {
|
||||
// Break at last space before maxWidth to avoid splitting mid-word
|
||||
const breakAt = text.lastIndexOf(" ", end);
|
||||
if (breakAt > start) end = breakAt + 1;
|
||||
} else {
|
||||
end = text.length;
|
||||
}
|
||||
|
||||
const segText = text.slice(start, end).trimEnd();
|
||||
|
||||
// Include chords whose offset falls within this segment; re-map offset
|
||||
const segChords = chords
|
||||
.filter((cp) => cp.offset >= start && cp.offset < end)
|
||||
.map((cp) => ({ ...cp, offset: cp.offset - start }));
|
||||
|
||||
segments.push({ text: segText, chords: segChords });
|
||||
|
||||
// Advance past the break point, skipping leading spaces for next segment
|
||||
start = end;
|
||||
while (start < text.length && text[start] === " ") start++;
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
function LineBlock({ line, sizeClass }: { line: LyricLine; sizeClass: string }) {
|
||||
function ChordRow({
|
||||
chords,
|
||||
sizeClass,
|
||||
onChordClick,
|
||||
}: {
|
||||
chords: { offset: number; chord: string }[];
|
||||
sizeClass: string;
|
||||
onChordClick?: (chord: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className={`relative font-mono ${sizeClass} text-primary`} style={{ height: '1.5em' }}>
|
||||
{chords.map(({ offset, chord }, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="absolute cursor-pointer hover:underline"
|
||||
style={{ left: `${offset}ch` }}
|
||||
onClick={() => onChordClick?.(chord)}
|
||||
>
|
||||
{chord}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LineBlock({
|
||||
line,
|
||||
sizeClass,
|
||||
onChordClick,
|
||||
}: {
|
||||
line: LyricLine;
|
||||
sizeClass: string;
|
||||
onChordClick?: (chord: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="leading-tight">
|
||||
{line.chords.length > 0 && (
|
||||
<pre className={`text-primary ${sizeClass} font-mono whitespace-pre`}>
|
||||
{buildChordRow(line.chords)}
|
||||
</pre>
|
||||
<ChordRow chords={line.chords} sizeClass={sizeClass} onChordClick={onChordClick} />
|
||||
)}
|
||||
{line.text && (
|
||||
<pre className={`text-foreground ${sizeClass} font-mono whitespace-pre`}>
|
||||
@@ -72,7 +83,15 @@ function LineBlock({ line, sizeClass }: { line: LyricLine; sizeClass: string })
|
||||
);
|
||||
}
|
||||
|
||||
function SectionBlock({ section, sizeClass }: { section: Section; sizeClass: string }) {
|
||||
function SectionBlock({
|
||||
section,
|
||||
sizeClass,
|
||||
onChordClick,
|
||||
}: {
|
||||
section: Section;
|
||||
sizeClass: string;
|
||||
onChordClick?: (chord: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-6">
|
||||
{section.label && (
|
||||
@@ -80,19 +99,19 @@ function SectionBlock({ section, sizeClass }: { section: Section; sizeClass: str
|
||||
)}
|
||||
{section.lines.flatMap((line, i) =>
|
||||
segmentLine(line, MAX_WIDTH).map((seg, j) => (
|
||||
<LineBlock key={`${i}-${j}`} line={seg} sizeClass={sizeClass} />
|
||||
<LineBlock key={`${i}-${j}`} line={seg} sizeClass={sizeClass} onChordClick={onChordClick} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChordChart({ sections, fontSize }: Props) {
|
||||
export function ChordChart({ sections, fontSize, onChordClick }: Props) {
|
||||
const sizeClass = { sm: 'text-sm', base: 'text-base', lg: 'text-lg' }[fontSize ?? 'sm'];
|
||||
return (
|
||||
<div className="px-4 py-3">
|
||||
{sections.map((section, i) => (
|
||||
<SectionBlock key={i} section={section} sizeClass={sizeClass} />
|
||||
<SectionBlock key={i} section={section} sizeClass={sizeClass} onChordClick={onChordClick} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
23
app/app/components/chord-diagram/chord-diagram.tsx
Normal file
23
app/app/components/chord-diagram/chord-diagram.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { getPianoNotes, getGuitarVoicing } from '~/lib/chord-voicing';
|
||||
import { PianoKeys } from './piano-keys';
|
||||
import { GuitarFretboard } from './guitar-fretboard';
|
||||
|
||||
export type Instrument = 'piano' | 'guitar';
|
||||
|
||||
interface Props {
|
||||
chord: string;
|
||||
instrument: Instrument;
|
||||
}
|
||||
|
||||
export function ChordDiagram({ chord, instrument }: Props) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 p-2">
|
||||
<span className="text-xs font-mono font-semibold text-primary">{chord}</span>
|
||||
{instrument === 'piano' ? (
|
||||
<PianoKeys notes={getPianoNotes(chord)} />
|
||||
) : (
|
||||
<GuitarFretboard voicing={getGuitarVoicing(chord)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
app/app/components/chord-diagram/chord-grid.tsx
Normal file
47
app/app/components/chord-diagram/chord-grid.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { ChordDiagram } from './chord-diagram';
|
||||
import type { Instrument } from './chord-diagram';
|
||||
|
||||
interface Props {
|
||||
chords: string[];
|
||||
instrument: Instrument;
|
||||
onInstrumentChange: (i: Instrument) => void;
|
||||
}
|
||||
|
||||
export function ChordGrid({ chords, instrument, onInstrumentChange }: Props) {
|
||||
if (chords.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="p-3">
|
||||
{/* Instrument toggle */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-xs text-muted-foreground">Chords</span>
|
||||
<div className="flex rounded-md border border-border overflow-hidden ml-auto">
|
||||
<button
|
||||
className={`px-2 py-1 text-xs transition-colors ${instrument === 'piano' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-accent'}`}
|
||||
onClick={() => onInstrumentChange('piano')}
|
||||
>
|
||||
Piano
|
||||
</button>
|
||||
<button
|
||||
className={`px-2 py-1 text-xs transition-colors ${instrument === 'guitar' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-accent'}`}
|
||||
onClick={() => onInstrumentChange('guitar')}
|
||||
>
|
||||
Guitar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chord cards */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{chords.map((chord) => (
|
||||
<div
|
||||
key={chord}
|
||||
className="rounded-md border border-border bg-card"
|
||||
>
|
||||
<ChordDiagram chord={chord} instrument={instrument} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
155
app/app/components/chord-diagram/guitar-fretboard.tsx
Normal file
155
app/app/components/chord-diagram/guitar-fretboard.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import type { GuitarVoicing } from '~/lib/chord-voicing';
|
||||
|
||||
const STRING_COUNT = 6;
|
||||
const FRET_COUNT = 4;
|
||||
const STRING_SPACING = 13; // px between adjacent string lines
|
||||
const FRET_HEIGHT = 18; // px per fret
|
||||
const DOT_R = 5; // finger dot radius
|
||||
|
||||
const GRID_W = STRING_SPACING * (STRING_COUNT - 1); // 65px
|
||||
const GRID_H = FRET_HEIGHT * FRET_COUNT; // 72px
|
||||
|
||||
interface Props {
|
||||
voicing: GuitarVoicing | null;
|
||||
}
|
||||
|
||||
export function GuitarFretboard({ voicing }: Props) {
|
||||
if (!voicing) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center text-muted-foreground text-xs"
|
||||
style={{ height: GRID_H + 20, width: GRID_W + 20 }}
|
||||
>
|
||||
no voicing
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { frets, baseFret, barre } = voicing;
|
||||
const showFretLabel = baseFret > 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
{/* O/X string indicators above nut */}
|
||||
<div className="relative" style={{ width: GRID_W, height: 16 }}>
|
||||
{frets.map((f, i) =>
|
||||
f === null ? (
|
||||
<span
|
||||
key={i}
|
||||
className="absolute font-mono text-destructive"
|
||||
style={{ fontSize: 10, top: 2, left: i * STRING_SPACING - 4 }}
|
||||
>
|
||||
✕
|
||||
</span>
|
||||
) : f === 0 ? (
|
||||
<span
|
||||
key={i}
|
||||
className="absolute font-mono text-muted-foreground"
|
||||
style={{ fontSize: 10, top: 2, left: i * STRING_SPACING - 5 }}
|
||||
>
|
||||
○
|
||||
</span>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fret number + grid */}
|
||||
<div className="flex items-start" style={{ gap: 4 }}>
|
||||
{/* Fret number label (barre position) */}
|
||||
<div style={{ width: 12, textAlign: 'right' }}>
|
||||
{showFretLabel && (
|
||||
<span
|
||||
className="font-mono text-muted-foreground"
|
||||
style={{ fontSize: 9, lineHeight: `${FRET_HEIGHT}px` }}
|
||||
>
|
||||
{baseFret}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fretboard grid */}
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
width: GRID_W,
|
||||
height: GRID_H,
|
||||
// Nut: thick when open position, thin when barre
|
||||
borderTop: showFretLabel
|
||||
? `1px solid var(--border)`
|
||||
: `4px solid var(--foreground)`,
|
||||
borderBottom: `1px solid var(--border)`,
|
||||
}}
|
||||
>
|
||||
{/* String lines (6 vertical lines) */}
|
||||
{Array.from({ length: STRING_COUNT }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: i * STRING_SPACING,
|
||||
width: 1,
|
||||
background: 'var(--foreground)',
|
||||
opacity: 0.35,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Fret lines (horizontal, between frets) */}
|
||||
{Array.from({ length: FRET_COUNT - 1 }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: (i + 1) * FRET_HEIGHT,
|
||||
height: 1,
|
||||
background: 'var(--border)',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Barre bar — only for transposed barre chords */}
|
||||
{barre !== null && barre > 0 && (
|
||||
<div
|
||||
className="absolute rounded-full"
|
||||
style={{
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: DOT_R * 2,
|
||||
top: (barre - baseFret) * FRET_HEIGHT + FRET_HEIGHT / 2 - DOT_R,
|
||||
background: 'var(--primary)',
|
||||
zIndex: 5,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Finger dots */}
|
||||
{frets.map((fret, stringIdx) => {
|
||||
if (fret === null || fret === 0) return null;
|
||||
// Skip strings covered by the barre bar
|
||||
if (barre !== null && fret === barre) return null;
|
||||
const fretIdx = baseFret === 0 ? fret - 1 : fret - baseFret;
|
||||
if (fretIdx < 0 || fretIdx >= FRET_COUNT) return null;
|
||||
return (
|
||||
<div
|
||||
key={stringIdx}
|
||||
className="absolute rounded-full"
|
||||
style={{
|
||||
width: DOT_R * 2,
|
||||
height: DOT_R * 2,
|
||||
left: stringIdx * STRING_SPACING - DOT_R,
|
||||
top: fretIdx * FRET_HEIGHT + FRET_HEIGHT / 2 - DOT_R,
|
||||
background: 'var(--primary)',
|
||||
zIndex: 10,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
161
app/app/components/chord-diagram/piano-keys.tsx
Normal file
161
app/app/components/chord-diagram/piano-keys.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
/** Chroma value (0=C … 11=B) for every note name tonal might return */
|
||||
const NOTE_CHROMA: Record<string, number> = {
|
||||
'C': 0, 'C#': 1, 'Db': 1,
|
||||
'D': 2, 'D#': 3, 'Eb': 3,
|
||||
'E': 4, 'Fb': 4,
|
||||
'F': 5, 'E#': 5, 'F#': 6, 'Gb': 6,
|
||||
'G': 7, 'G#': 8, 'Ab': 8,
|
||||
'A': 9, 'A#': 10, 'Bb': 10,
|
||||
'B': 11, 'Cb': 11, 'B#': 0,
|
||||
// Double-sharps tonal may return (e.g. Baug → F##=G)
|
||||
'C##': 2, 'D##': 4, 'F##': 7, 'G##': 9, 'A##': 11,
|
||||
};
|
||||
|
||||
const WHITE_KEYS = ['C', 'D', 'E', 'F', 'G', 'A', 'B'];
|
||||
const WHITE_KEY_W = 16; // px
|
||||
const WHITE_KEY_H = 62; // px
|
||||
const BLACK_KEY_W = 10; // px
|
||||
const BLACK_KEY_H = 38; // px
|
||||
|
||||
/** Left offset (px) of each black key from the left edge of the keyboard */
|
||||
const BLACK_KEY_LEFT: Record<string, number> = {
|
||||
'C#': 1 * WHITE_KEY_W - BLACK_KEY_W / 2,
|
||||
'D#': 2 * WHITE_KEY_W - BLACK_KEY_W / 2,
|
||||
'F#': 4 * WHITE_KEY_W - BLACK_KEY_W / 2,
|
||||
'G#': 5 * WHITE_KEY_W - BLACK_KEY_W / 2,
|
||||
'A#': 6 * WHITE_KEY_W - BLACK_KEY_W / 2,
|
||||
};
|
||||
|
||||
const BLACK_KEY_NAMES = Object.keys(BLACK_KEY_LEFT);
|
||||
|
||||
interface Props {
|
||||
/** Note names from tonal, e.g. ["C","E","G"] or ["Ab","C","Eb"] */
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
export function PianoKeys({ notes }: Props) {
|
||||
const activeChroma = new Set(
|
||||
notes.map((n) => NOTE_CHROMA[n]).filter((c) => c !== undefined)
|
||||
);
|
||||
const totalWidth = WHITE_KEY_W * 7;
|
||||
|
||||
if (notes.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center text-muted-foreground text-xs"
|
||||
style={{ width: totalWidth, height: WHITE_KEY_H + 26 }}
|
||||
>
|
||||
?
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: totalWidth }}>
|
||||
{/* Keyboard — gray container makes keys visible in any theme */}
|
||||
<div
|
||||
className="relative rounded-sm"
|
||||
style={{ width: totalWidth, height: WHITE_KEY_H, background: '#d0d0d0' }}
|
||||
>
|
||||
{/* White keys */}
|
||||
{WHITE_KEYS.map((note, i) => {
|
||||
const active = activeChroma.has(NOTE_CHROMA[note]);
|
||||
return (
|
||||
<div
|
||||
key={note}
|
||||
className="absolute rounded-b-sm"
|
||||
style={{
|
||||
left: i * WHITE_KEY_W + 1,
|
||||
top: 0,
|
||||
width: WHITE_KEY_W - 2,
|
||||
height: WHITE_KEY_H - 1,
|
||||
background: 'white',
|
||||
borderBottom: active ? '3px solid var(--primary)' : '1px solid #bbb',
|
||||
}}
|
||||
>
|
||||
{active && (
|
||||
<div
|
||||
className="absolute rounded-full"
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
bottom: 10,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: 'var(--primary)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Black keys (rendered on top) */}
|
||||
{BLACK_KEY_NAMES.map((note) => {
|
||||
const active = activeChroma.has(NOTE_CHROMA[note]);
|
||||
return (
|
||||
<div
|
||||
key={note}
|
||||
className="absolute z-10 rounded-b-sm"
|
||||
style={{
|
||||
left: BLACK_KEY_LEFT[note],
|
||||
top: 0,
|
||||
width: BLACK_KEY_W,
|
||||
height: BLACK_KEY_H,
|
||||
background: active ? 'var(--primary)' : '#1c1c1c',
|
||||
}}
|
||||
>
|
||||
{active && (
|
||||
<div
|
||||
className="absolute rounded-full bg-white"
|
||||
style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
bottom: 5,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* White key note labels */}
|
||||
<div className="flex" style={{ width: totalWidth, marginTop: 2 }}>
|
||||
{WHITE_KEYS.map((note) => {
|
||||
const active = activeChroma.has(NOTE_CHROMA[note]);
|
||||
return (
|
||||
<div
|
||||
key={note}
|
||||
className="text-center font-mono select-none"
|
||||
style={{
|
||||
width: WHITE_KEY_W,
|
||||
fontSize: 8,
|
||||
lineHeight: '12px',
|
||||
color: active ? 'var(--primary)' : undefined,
|
||||
fontWeight: active ? 700 : undefined,
|
||||
}}
|
||||
>
|
||||
{note}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Active note names — clearly lists all notes including black keys */}
|
||||
<div className="flex flex-wrap justify-center gap-1" style={{ marginTop: 2 }}>
|
||||
{notes.map((note) => (
|
||||
<span
|
||||
key={note}
|
||||
className="font-mono font-semibold"
|
||||
style={{ fontSize: 9, color: 'var(--primary)' }}
|
||||
>
|
||||
{note}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
app/app/lib/chord-voicing.test.ts
Normal file
73
app/app/lib/chord-voicing.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getPianoNotes, getGuitarVoicing } from './chord-voicing';
|
||||
|
||||
describe('getPianoNotes', () => {
|
||||
it('returns note names for a major chord', () => {
|
||||
expect(getPianoNotes('C')).toEqual(['C', 'E', 'G']);
|
||||
});
|
||||
|
||||
it('returns note names for Cmaj7', () => {
|
||||
expect(getPianoNotes('Cmaj7')).toEqual(['C', 'E', 'G', 'B']);
|
||||
});
|
||||
|
||||
it('returns note names for Am', () => {
|
||||
expect(getPianoNotes('Am')).toEqual(['A', 'C', 'E']);
|
||||
});
|
||||
|
||||
it('returns [] for unparseable chord', () => {
|
||||
expect(getPianoNotes('???')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns [] for empty string', () => {
|
||||
expect(getPianoNotes('')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGuitarVoicing', () => {
|
||||
it('returns voicing for E major (open position, baseFret=0)', () => {
|
||||
const v = getGuitarVoicing('E');
|
||||
expect(v).not.toBeNull();
|
||||
expect(v!.baseFret).toBe(0);
|
||||
expect(v!.frets).toEqual([0, 2, 2, 1, 0, 0]);
|
||||
});
|
||||
|
||||
it('returns voicing for Am (open position, baseFret=0)', () => {
|
||||
const v = getGuitarVoicing('Am');
|
||||
expect(v).not.toBeNull();
|
||||
expect(v!.baseFret).toBe(0);
|
||||
expect(v!.frets).toEqual([null, 0, 2, 2, 1, 0]);
|
||||
});
|
||||
|
||||
it('transposes Bm correctly (A-shape, shift=2)', () => {
|
||||
const v = getGuitarVoicing('Bm');
|
||||
expect(v).not.toBeNull();
|
||||
// Am shifted up 2: [null,2,4,4,3,2], baseFret=2
|
||||
expect(v!.baseFret).toBe(2);
|
||||
expect(v!.frets).toEqual([null, 2, 4, 4, 3, 2]);
|
||||
});
|
||||
|
||||
it('returns open G major voicing from named table', () => {
|
||||
const v = getGuitarVoicing('G');
|
||||
expect(v).not.toBeNull();
|
||||
// Named open G: [3,2,0,0,0,3], baseFret=0
|
||||
expect(v!.baseFret).toBe(0);
|
||||
expect(v!.frets).toEqual([3, 2, 0, 0, 0, 3]);
|
||||
});
|
||||
|
||||
it('falls back to transposition for F# major (not in named table)', () => {
|
||||
const v = getGuitarVoicing('F#');
|
||||
expect(v).not.toBeNull();
|
||||
// E-shape shift=2: [2,4,4,3,2,2], baseFret=2
|
||||
expect(v!.baseFret).toBe(2);
|
||||
expect(v!.frets).toEqual([2, 4, 4, 3, 2, 2]);
|
||||
});
|
||||
|
||||
it('returns null for unknown quality', () => {
|
||||
// 'add9' is not in the voicing map
|
||||
expect(getGuitarVoicing('Cadd9')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for unparseable chord', () => {
|
||||
expect(getGuitarVoicing('???')).toBeNull();
|
||||
});
|
||||
});
|
||||
122
app/app/lib/chord-voicing.ts
Normal file
122
app/app/lib/chord-voicing.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { Chord, Note } from 'tonal';
|
||||
import { GUITAR_VOICINGS } from './guitar-voicings';
|
||||
|
||||
export interface GuitarVoicing {
|
||||
/** Absolute fret numbers per string (low→high); null = muted, 0 = open */
|
||||
frets: (number | null)[];
|
||||
/** Lowest fret displayed on the diagram (0 = show nut) */
|
||||
baseFret: number;
|
||||
/** Absolute fret to draw a barre bar across, or null */
|
||||
barre: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Named open (and common barre) chord voicings for specific chord strings.
|
||||
* Checked before the algorithmic transposition, so G major shows the open
|
||||
* G chord [3,2,0,0,0,3] instead of a barre at fret 3.
|
||||
*/
|
||||
const GUITAR_NAMED_VOICINGS: Record<string, GuitarVoicing> = {
|
||||
// ── Major ──────────────────────────────────────────────────────────────
|
||||
'E': { frets: [0, 2, 2, 1, 0, 0], baseFret: 0, barre: null },
|
||||
'A': { frets: [null, 0, 2, 2, 2, 0], baseFret: 0, barre: null },
|
||||
'D': { frets: [null, null, 0, 2, 3, 2], baseFret: 0, barre: null },
|
||||
'G': { frets: [3, 2, 0, 0, 0, 3], baseFret: 0, barre: null },
|
||||
'C': { frets: [null, 3, 2, 0, 1, 0], baseFret: 0, barre: null },
|
||||
'F': { frets: [1, 3, 3, 2, 1, 1], baseFret: 1, barre: 1 },
|
||||
'B': { frets: [null, 2, 4, 4, 4, 2], baseFret: 2, barre: 2 },
|
||||
'Bb': { frets: [null, 1, 3, 3, 3, 1], baseFret: 1, barre: 1 },
|
||||
'Ab': { frets: [null, null, 6, 5, 4, 4], baseFret: 4, barre: null },
|
||||
|
||||
// ── Minor ───────────────────────────────────────────────────────────────
|
||||
'Em': { frets: [0, 2, 2, 0, 0, 0], baseFret: 0, barre: null },
|
||||
'Am': { frets: [null, 0, 2, 2, 1, 0], baseFret: 0, barre: null },
|
||||
'Dm': { frets: [null, null, 0, 2, 3, 1], baseFret: 0, barre: null },
|
||||
'Gm': { frets: [3, 5, 5, 3, 3, 3], baseFret: 3, barre: 3 },
|
||||
'Cm': { frets: [null, 3, 5, 5, 4, 3], baseFret: 3, barre: 3 },
|
||||
'Fm': { frets: [1, 3, 3, 1, 1, 1], baseFret: 1, barre: 1 },
|
||||
'Bm': { frets: [null, 2, 4, 4, 3, 2], baseFret: 2, barre: 2 },
|
||||
'Bbm': { frets: [null, 1, 3, 3, 2, 1], baseFret: 1, barre: 1 },
|
||||
|
||||
// ── Dominant 7th ────────────────────────────────────────────────────────
|
||||
'E7': { frets: [0, 2, 0, 1, 0, 0], baseFret: 0, barre: null },
|
||||
'A7': { frets: [null, 0, 2, 0, 2, 0], baseFret: 0, barre: null },
|
||||
'D7': { frets: [null, null, 0, 2, 1, 2], baseFret: 0, barre: null },
|
||||
'G7': { frets: [3, 2, 0, 0, 0, 1], baseFret: 0, barre: null },
|
||||
'C7': { frets: [null, 3, 2, 3, 1, 0], baseFret: 0, barre: null },
|
||||
'B7': { frets: [null, 2, 1, 2, 0, 2], baseFret: 0, barre: null },
|
||||
'F7': { frets: [1, 3, 1, 2, 1, 1], baseFret: 1, barre: 1 },
|
||||
|
||||
// ── Major 7th ────────────────────────────────────────────────────────────
|
||||
'Emaj7': { frets: [0, 2, 1, 1, 0, 0], baseFret: 0, barre: null },
|
||||
'Amaj7': { frets: [null, 0, 2, 1, 2, 0], baseFret: 0, barre: null },
|
||||
'Dmaj7': { frets: [null, null, 0, 2, 2, 2], baseFret: 0, barre: null },
|
||||
'Gmaj7': { frets: [3, 2, 0, 0, 0, 2], baseFret: 0, barre: null },
|
||||
'Cmaj7': { frets: [null, 3, 2, 0, 0, 0], baseFret: 0, barre: null },
|
||||
'Fmaj7': { frets: [null, null, 3, 2, 1, 0], baseFret: 0, barre: null },
|
||||
|
||||
// ── Minor 7th ────────────────────────────────────────────────────────────
|
||||
'Em7': { frets: [0, 2, 0, 0, 0, 0], baseFret: 0, barre: null },
|
||||
'Am7': { frets: [null, 0, 2, 0, 1, 0], baseFret: 0, barre: null },
|
||||
'Dm7': { frets: [null, null, 0, 2, 1, 1], baseFret: 0, barre: null },
|
||||
'Gm7': { frets: [3, 5, 3, 3, 3, 3], baseFret: 3, barre: 3 },
|
||||
'Bm7': { frets: [null, 2, 4, 2, 3, 2], baseFret: 2, barre: 2 },
|
||||
|
||||
// ── Suspended 4th ────────────────────────────────────────────────────────
|
||||
'Esus4': { frets: [0, 2, 2, 2, 0, 0], baseFret: 0, barre: null },
|
||||
'Asus4': { frets: [null, 0, 2, 2, 3, 0], baseFret: 0, barre: null },
|
||||
'Dsus4': { frets: [null, null, 0, 2, 3, 3], baseFret: 0, barre: null },
|
||||
'Gsus4': { frets: [3, 3, 0, 0, 1, 3], baseFret: 0, barre: null },
|
||||
|
||||
// ── Suspended 2nd ────────────────────────────────────────────────────────
|
||||
'Esus2': { frets: [0, 2, 4, 4, 0, 0], baseFret: 0, barre: null },
|
||||
'Asus2': { frets: [null, 0, 2, 2, 0, 0], baseFret: 0, barre: null },
|
||||
'Dsus2': { frets: [null, null, 0, 2, 3, 0], baseFret: 0, barre: null },
|
||||
};
|
||||
|
||||
const ROOT_STRING_CHROMA: Record<'E' | 'A', number> = {
|
||||
E: Note.chroma('E')!, // 4
|
||||
A: Note.chroma('A')!, // 9
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the note names (e.g. ["C","E","G"]) for a chord string.
|
||||
* Returns [] if the chord cannot be parsed.
|
||||
*/
|
||||
export function getPianoNotes(chord: string): string[] {
|
||||
if (!chord) return [];
|
||||
const parsed = Chord.get(chord);
|
||||
if (!parsed.tonic || parsed.empty) return [];
|
||||
return parsed.notes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a GuitarVoicing for a chord string, or null if unavailable.
|
||||
* Checks named open-chord voicings first, then falls back to algorithmic
|
||||
* barre-chord transposition via quality templates.
|
||||
*/
|
||||
export function getGuitarVoicing(chord: string): GuitarVoicing | null {
|
||||
if (!chord) return null;
|
||||
|
||||
// 1. Named lookup — preferred open-position voicings for common chords
|
||||
const named = GUITAR_NAMED_VOICINGS[chord];
|
||||
if (named) return named;
|
||||
|
||||
// 2. Algorithmic fallback — transpose quality template by chord root
|
||||
const parsed = Chord.get(chord);
|
||||
if (!parsed.tonic || parsed.empty) return null;
|
||||
|
||||
const template = GUITAR_VOICINGS[parsed.type];
|
||||
if (!template) return null;
|
||||
|
||||
const rootChroma = ROOT_STRING_CHROMA[template.rootString];
|
||||
const tonicChroma = Note.chroma(parsed.tonic);
|
||||
if (tonicChroma === undefined) return null;
|
||||
|
||||
const shift = (tonicChroma - rootChroma + 12) % 12;
|
||||
|
||||
return {
|
||||
frets: template.frets.map((f) => (f === null ? null : f + shift)),
|
||||
baseFret: shift,
|
||||
barre: template.barre === null ? null : template.barre + shift,
|
||||
};
|
||||
}
|
||||
88
app/app/lib/guitar-voicings.ts
Normal file
88
app/app/lib/guitar-voicings.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
export interface GuitarVoicingTemplate {
|
||||
/** 6 strings low→high; 0 = root position, 1 = one fret above root, null = muted */
|
||||
frets: (number | null)[];
|
||||
/** Fret (0-based relative) where a full barre is drawn, or null */
|
||||
barre: number | null;
|
||||
/** Which open string carries the root — determines transposition offset */
|
||||
rootString: 'E' | 'A';
|
||||
}
|
||||
|
||||
/**
|
||||
* Moveable barre-chord templates keyed by tonal chord type name.
|
||||
* Verified fingerings at root = E (E-shape) or root = A (A-shape).
|
||||
* To add a new quality: look up `Chord.get('<example>').type` in tonal,
|
||||
* then define the fingering at root E or A and add it here.
|
||||
*/
|
||||
export const GUITAR_VOICINGS: Record<string, GuitarVoicingTemplate> = {
|
||||
// ── E-shape (root on 6th string) ──────────────────────────────────────
|
||||
// E major open: [0,2,2,1,0,0] E B E G# B E
|
||||
'major': {
|
||||
frets: [0, 2, 2, 1, 0, 0],
|
||||
barre: 0,
|
||||
rootString: 'E',
|
||||
},
|
||||
// E7: [0,2,0,1,0,0] E B D G# B E
|
||||
'dominant seventh': {
|
||||
frets: [0, 2, 0, 1, 0, 0],
|
||||
barre: 0,
|
||||
rootString: 'E',
|
||||
},
|
||||
// Emaj7: [0,2,1,1,0,0] E B D# G# B E
|
||||
'major seventh': {
|
||||
frets: [0, 2, 1, 1, 0, 0],
|
||||
barre: 0,
|
||||
rootString: 'E',
|
||||
},
|
||||
// Eaug: [0,3,2,1,1,0] E C(=B#) E G# C E
|
||||
'augmented': {
|
||||
frets: [0, 3, 2, 1, 1, 0],
|
||||
barre: 0,
|
||||
rootString: 'E',
|
||||
},
|
||||
// Esus4: [0,2,2,2,0,0] E B E A B E
|
||||
'suspended fourth': {
|
||||
frets: [0, 2, 2, 2, 0, 0],
|
||||
barre: 0,
|
||||
rootString: 'E',
|
||||
},
|
||||
|
||||
// ── A-shape (root on 5th string) ──────────────────────────────────────
|
||||
// Am open: [x,0,2,2,1,0] A E A C E
|
||||
// barre: 0 so that transposed versions (Bm, Cm, etc.) draw the barre bar;
|
||||
// the renderer suppresses the barre when baseFret===0 (open position = no barre needed)
|
||||
'minor': {
|
||||
frets: [null, 0, 2, 2, 1, 0],
|
||||
barre: 0,
|
||||
rootString: 'A',
|
||||
},
|
||||
// Am7: [x,0,2,0,1,0] A E G C E
|
||||
'minor seventh': {
|
||||
frets: [null, 0, 2, 0, 1, 0],
|
||||
barre: null,
|
||||
rootString: 'A',
|
||||
},
|
||||
// AmMaj7: [x,0,2,1,1,0] A E G# C E
|
||||
'minor major seventh': {
|
||||
frets: [null, 0, 2, 1, 1, 0],
|
||||
barre: null,
|
||||
rootString: 'A',
|
||||
},
|
||||
// Adim: [x,0,1,2,1,x] A Eb A C (string 1 muted)
|
||||
'diminished': {
|
||||
frets: [null, 0, 1, 2, 1, null],
|
||||
barre: null,
|
||||
rootString: 'A',
|
||||
},
|
||||
// Am7b5 (half-dim): [x,0,1,0,1,x] A Eb G C
|
||||
'half-diminished': {
|
||||
frets: [null, 0, 1, 0, 1, null],
|
||||
barre: null,
|
||||
rootString: 'A',
|
||||
},
|
||||
// Asus2: [x,0,2,2,0,0] A E A B E
|
||||
'suspended second': {
|
||||
frets: [null, 0, 2, 2, 0, 0],
|
||||
barre: null,
|
||||
rootString: 'A',
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Song } from "./types";
|
||||
import type { Song, Section } from "./types";
|
||||
|
||||
export function previewChords(song: Song): string[] {
|
||||
const seen = new Set<string>();
|
||||
@@ -16,3 +16,20 @@ export function previewChords(song: Song): string[] {
|
||||
}
|
||||
return result.slice(0, 5);
|
||||
}
|
||||
|
||||
/** All unique chord names in order of first appearance across all sections. */
|
||||
export function extractUniqueChords(sections: Section[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const section of sections) {
|
||||
for (const line of section.lines) {
|
||||
for (const cp of line.chords) {
|
||||
if (!seen.has(cp.chord)) {
|
||||
seen.add(cp.chord);
|
||||
result.push(cp.chord);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useRef, useCallback } 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 { ChordGrid } from "~/components/chord-diagram/chord-grid";
|
||||
import { ChordDiagram } from "~/components/chord-diagram/chord-diagram";
|
||||
import type { Instrument } from "~/components/chord-diagram/chord-diagram";
|
||||
import { EditSongSheet } from "~/components/edit-song-sheet";
|
||||
import { DeleteSongDialog } from "~/components/delete-song-dialog";
|
||||
import { transposeSong } from "~/lib/transpose";
|
||||
import { extractUniqueChords } from "~/lib/song-utils";
|
||||
import { getSong } from "~/lib/api";
|
||||
import type { Song, SongSummary } from "~/lib/types";
|
||||
|
||||
@@ -41,6 +45,14 @@ function initFontSize(): FontSize {
|
||||
return 'sm';
|
||||
}
|
||||
|
||||
function initInstrument(): Instrument {
|
||||
try {
|
||||
const v = localStorage.getItem('chordDiagramInstrument');
|
||||
if (v === 'piano' || v === 'guitar') return v;
|
||||
} catch { /* noop */ }
|
||||
return 'piano';
|
||||
}
|
||||
|
||||
export default function SongDetail({ loaderData }: Route.ComponentProps) {
|
||||
const { song: initialSong, id } = loaderData;
|
||||
const [baseSong, setBaseSong] = useState<Song | null>(initialSong ?? null);
|
||||
@@ -62,6 +74,9 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
|
||||
const [fontSize, setFontSize] = useState<FontSize>(initFontSize);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [activeChord, setActiveChord] = useState<string | null>(null);
|
||||
const [instrument, setInstrument] = useState<Instrument>(initInstrument);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (applyCapo && baseSong?.meta.capo) {
|
||||
@@ -81,6 +96,13 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
|
||||
try { localStorage.setItem('fontSize', size); } catch { /* noop */ }
|
||||
}
|
||||
|
||||
function handleInstrumentChange(i: Instrument) {
|
||||
setInstrument(i);
|
||||
try { localStorage.setItem('chordDiagramInstrument', i); } catch { /* noop */ }
|
||||
}
|
||||
|
||||
const handleScroll = useCallback(() => setActiveChord(null), []);
|
||||
|
||||
if (!baseSong || !displayedSong) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4">
|
||||
@@ -93,6 +115,8 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
|
||||
}
|
||||
|
||||
const displayed = transposeSong(displayedSong, offset);
|
||||
const uniqueChords = extractUniqueChords(displayed.sections);
|
||||
const handleChordClick = (chord: string) => setActiveChord(chord);
|
||||
|
||||
function handleUpdated(summary: SongSummary) {
|
||||
setBaseSong((prev) => prev ? { ...prev, meta: summary.meta } : prev);
|
||||
@@ -100,7 +124,7 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full max-w-lg mx-auto">
|
||||
<div className="flex flex-col h-full">
|
||||
<TransposeBar
|
||||
meta={baseSong.meta}
|
||||
offset={offset}
|
||||
@@ -113,9 +137,56 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
|
||||
applyCapo={applyCapo}
|
||||
onToggleCapo={() => setApplyCapo((v) => !v)}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<ChordChart sections={displayed.sections} fontSize={fontSize} />
|
||||
|
||||
{/* Body: single column on mobile, two columns on desktop */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col lg:flex-row">
|
||||
{/* Left / main column */}
|
||||
<div
|
||||
className="flex-1 overflow-y-auto"
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div className="max-w-lg mx-auto lg:max-w-none">
|
||||
<ChordChart
|
||||
sections={displayed.sections}
|
||||
fontSize={fontSize}
|
||||
onChordClick={handleChordClick}
|
||||
/>
|
||||
|
||||
{/* Mobile bottom chord grid (hidden on desktop) */}
|
||||
<div className="lg:hidden border-t border-border">
|
||||
<ChordGrid
|
||||
chords={uniqueChords}
|
||||
instrument={instrument}
|
||||
onInstrumentChange={handleInstrumentChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop side column (hidden on mobile) */}
|
||||
<div className="hidden lg:block w-72 overflow-y-auto border-l border-border shrink-0">
|
||||
<ChordGrid
|
||||
chords={uniqueChords}
|
||||
instrument={instrument}
|
||||
onInstrumentChange={handleInstrumentChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile inline popup — fixed bottom, dismissed on scroll */}
|
||||
{activeChord && (
|
||||
<div className="lg:hidden fixed bottom-0 left-0 right-0 z-50 border-t border-border bg-background shadow-lg p-3 flex items-center gap-3">
|
||||
<ChordDiagram chord={activeChord} instrument={instrument} />
|
||||
<button
|
||||
className="ml-auto text-muted-foreground text-xs underline-offset-4 hover:underline"
|
||||
onClick={() => setActiveChord(null)}
|
||||
>
|
||||
close
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<EditSongSheet
|
||||
id={id}
|
||||
meta={baseSong.meta}
|
||||
|
||||
9335
app/package-lock.json
generated
Normal file
9335
app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@
|
||||
"build": "react-router build",
|
||||
"dev": "react-router dev",
|
||||
"start": "react-router-serve ./build/server/index.js",
|
||||
"test": "vitest run",
|
||||
"typecheck": "react-router typegen && tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -32,6 +33,7 @@
|
||||
"shadcn": "^4.2.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tonal": "^6.4.3",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vaul": "^1.1.2"
|
||||
},
|
||||
@@ -43,6 +45,7 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^8.0.3"
|
||||
"vite": "^8.0.3",
|
||||
"vitest": "^4.1.3"
|
||||
}
|
||||
}
|
||||
13
app/vitest.config.ts
Normal file
13
app/vitest.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { resolve } from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'~': resolve(__dirname, './app'),
|
||||
},
|
||||
},
|
||||
});
|
||||
52
crates/api/src/config.rs
Normal file
52
crates/api/src/config.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
use std::env;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Config {
|
||||
pub database_url: String,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
/// Parsed CORS origin policy
|
||||
pub cors_origins: CorsOrigins,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum CorsOrigins {
|
||||
/// Allow any origin (`CORS_ALLOWED_ORIGINS=*`)
|
||||
Any,
|
||||
/// Allow specific origins (`CORS_ALLOWED_ORIGINS=https://a.com,https://b.com`)
|
||||
List(Vec<String>),
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> Self {
|
||||
let database_url = env::var("DATABASE_URL")
|
||||
.unwrap_or_else(|_| "sqlite://./pocket-chords.db".into());
|
||||
|
||||
let host = env::var("HOST").unwrap_or_else(|_| "0.0.0.0".into());
|
||||
|
||||
let port = env::var("PORT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u16>().ok())
|
||||
.unwrap_or(8000);
|
||||
|
||||
let cors_origins = match env::var("CORS_ALLOWED_ORIGINS")
|
||||
.unwrap_or_else(|_| "*".into())
|
||||
.trim()
|
||||
.to_string()
|
||||
{
|
||||
s if s == "*" => CorsOrigins::Any,
|
||||
s => CorsOrigins::List(
|
||||
s.split(',')
|
||||
.map(|o| o.trim().to_string())
|
||||
.filter(|o| !o.is_empty())
|
||||
.collect(),
|
||||
),
|
||||
};
|
||||
|
||||
Self { database_url, host, port, cors_origins }
|
||||
}
|
||||
|
||||
pub fn bind_addr(&self) -> String {
|
||||
format!("{}:{}", self.host, self.port)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
mod config;
|
||||
mod routes;
|
||||
|
||||
use axum::{Router, routing::{get, post}};
|
||||
use axum::{Router, http::HeaderValue, routing::{get, post}};
|
||||
use common::{SongSearchService, SongService};
|
||||
use config::{Config, CorsOrigins};
|
||||
use persistence::SqliteRepositoryFactory;
|
||||
use routes::songs::{create_song, delete_song, get_song, list_songs, update_song};
|
||||
use routes::tabs::{AppState, parse_tab};
|
||||
@@ -13,9 +15,10 @@ use ug_parser::{UgHtmlParser, UgTabFetcher};
|
||||
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)
|
||||
let config = Config::from_env();
|
||||
tracing::info!(?config, "starting with config");
|
||||
|
||||
let repo = SqliteRepositoryFactory::create(&config.database_url)
|
||||
.await
|
||||
.expect("failed to connect to database");
|
||||
let songs = SongService::new(Box::new(repo.clone()));
|
||||
@@ -28,10 +31,22 @@ async fn main() {
|
||||
search,
|
||||
});
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
let cors = match config.cors_origins {
|
||||
CorsOrigins::Any => CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
.allow_headers(Any),
|
||||
CorsOrigins::List(ref origins) => {
|
||||
let parsed: Vec<HeaderValue> = origins
|
||||
.iter()
|
||||
.map(|o| o.parse().unwrap_or_else(|_| panic!("invalid CORS origin: {o}")))
|
||||
.collect();
|
||||
CorsLayer::new()
|
||||
.allow_origin(parsed)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any)
|
||||
}
|
||||
};
|
||||
|
||||
let app = Router::new()
|
||||
.route("/tabs/parse", post(parse_tab))
|
||||
@@ -40,7 +55,9 @@ async fn main() {
|
||||
.layer(cors)
|
||||
.with_state(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:8000").await.unwrap();
|
||||
let addr = config.bind_addr();
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await
|
||||
.unwrap_or_else(|e| panic!("failed to bind {addr}: {e}"));
|
||||
tracing::info!("listening on {}", listener.local_addr().unwrap());
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
|
||||
@@ -1,219 +1,5 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
RepositoryError, Song, SongMeta, SongRepositoryPort, SongSearchPort, SongSummary, StoredSong,
|
||||
SortField, SortOrder,
|
||||
song_preview_chords,
|
||||
};
|
||||
use sqlx::SqlitePool;
|
||||
use uuid::Uuid;
|
||||
mod row;
|
||||
pub mod repository;
|
||||
mod search;
|
||||
|
||||
fn sort_clause(field: SortField, order: SortOrder) -> &'static str {
|
||||
match (field, order) {
|
||||
(SortField::Title, SortOrder::Asc) => "ORDER BY title ASC",
|
||||
(SortField::Title, SortOrder::Desc) => "ORDER BY title DESC",
|
||||
(SortField::Artist, SortOrder::Asc) => "ORDER BY artist ASC",
|
||||
(SortField::Artist, SortOrder::Desc) => "ORDER BY artist DESC",
|
||||
(SortField::Date, SortOrder::Asc) => "ORDER BY created_at ASC",
|
||||
(SortField::Date, SortOrder::Desc) => "ORDER BY created_at DESC",
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SqliteSongRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteSongRepository {
|
||||
pub async fn new(database_url: &str) -> Result<Self, sqlx::Error> {
|
||||
let pool = SqlitePool::connect(database_url).await?;
|
||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||
Ok(Self { pool })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct SongRow {
|
||||
id: String,
|
||||
title: String,
|
||||
artist: String,
|
||||
original_key: Option<String>,
|
||||
preview_chords: String,
|
||||
body: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SongRepositoryPort for SqliteSongRepository {
|
||||
async fn save(&self, song: &Song) -> Result<StoredSong, RepositoryError> {
|
||||
let id = Uuid::new_v4();
|
||||
let id_str = id.to_string();
|
||||
let body = serde_json::to_string(song)
|
||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||
let preview = song_preview_chords(song);
|
||||
let preview_json = serde_json::to_string(&preview)
|
||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||
let original_key = song.meta.original_key.as_deref();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO songs (id, title, artist, original_key, preview_chords, body) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(&id_str)
|
||||
.bind(&song.meta.title)
|
||||
.bind(&song.meta.artist)
|
||||
.bind(original_key)
|
||||
.bind(&preview_json)
|
||||
.bind(&body)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||
|
||||
Ok(StoredSong { id, song: song.clone() })
|
||||
}
|
||||
|
||||
async fn list(&self, sort: SortField, order: SortOrder) -> Result<Vec<SongSummary>, RepositoryError> {
|
||||
let sql = format!(
|
||||
"SELECT id, title, artist, original_key, preview_chords, body FROM songs {}",
|
||||
sort_clause(sort, order)
|
||||
);
|
||||
let rows = sqlx::query_as::<_, SongRow>(&sql)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||
|
||||
rows.into_iter().map(row_to_summary).collect()
|
||||
}
|
||||
|
||||
async fn get(&self, id: Uuid) -> Result<Option<Song>, RepositoryError> {
|
||||
let id_str = id.to_string();
|
||||
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()))?;
|
||||
|
||||
match row {
|
||||
None => Ok(None),
|
||||
Some(r) => {
|
||||
let song: Song = serde_json::from_str(&r.body)
|
||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||
Ok(Some(song))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete(&self, id: Uuid) -> Result<(), RepositoryError> {
|
||||
let id_str = id.to_string();
|
||||
let result = sqlx::query("DELETE FROM songs WHERE id = ?")
|
||||
.bind(&id_str)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
Err(RepositoryError::NotFound)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
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)?;
|
||||
|
||||
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: Option<&str> = 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SongSearchPort for SqliteSongRepository {
|
||||
async fn search(&self, query: &str, sort: SortField, order: SortOrder) -> Result<Vec<SongSummary>, RepositoryError> {
|
||||
let escaped = query.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
|
||||
let pattern = format!("%{}%", escaped);
|
||||
let sql = format!(
|
||||
"SELECT id, title, artist, original_key, preview_chords, body FROM songs \
|
||||
WHERE (title LIKE ? ESCAPE '\\' OR artist LIKE ? ESCAPE '\\') {}",
|
||||
sort_clause(sort, order)
|
||||
);
|
||||
let rows = sqlx::query_as::<_, SongRow>(&sql)
|
||||
.bind(&pattern)
|
||||
.bind(&pattern)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||
|
||||
rows.into_iter().map(row_to_summary).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_summary(row: SongRow) -> Result<SongSummary, RepositoryError> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
pub struct SqliteRepositoryFactory;
|
||||
|
||||
impl SqliteRepositoryFactory {
|
||||
pub async fn create(database_url: &str) -> Result<SqliteSongRepository, sqlx::Error> {
|
||||
SqliteSongRepository::new(database_url).await
|
||||
}
|
||||
}
|
||||
pub use repository::{SqliteSongRepository, SqliteRepositoryFactory};
|
||||
|
||||
159
crates/infrastructure/persistence/src/repository.rs
Normal file
159
crates/infrastructure/persistence/src/repository.rs
Normal file
@@ -0,0 +1,159 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
RepositoryError, Song, SongRepositoryPort, SongSummary, StoredSong,
|
||||
SortField, SortOrder, song_preview_chords,
|
||||
};
|
||||
use sqlx::SqlitePool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::row::{SongRow, sort_clause, row_to_summary};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SqliteSongRepository {
|
||||
pub(crate) pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteSongRepository {
|
||||
pub async fn new(database_url: &str) -> Result<Self, sqlx::Error> {
|
||||
let pool = SqlitePool::connect(database_url).await?;
|
||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||
Ok(Self { pool })
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SongRepositoryPort for SqliteSongRepository {
|
||||
async fn save(&self, song: &Song) -> Result<StoredSong, RepositoryError> {
|
||||
let id = Uuid::new_v4();
|
||||
let id_str = id.to_string();
|
||||
let body = serde_json::to_string(song)
|
||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||
let preview = song_preview_chords(song);
|
||||
let preview_json = serde_json::to_string(&preview)
|
||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||
let original_key = song.meta.original_key.as_deref();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO songs (id, title, artist, original_key, preview_chords, body) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(&id_str)
|
||||
.bind(&song.meta.title)
|
||||
.bind(&song.meta.artist)
|
||||
.bind(original_key)
|
||||
.bind(&preview_json)
|
||||
.bind(&body)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||
|
||||
Ok(StoredSong { id, song: song.clone() })
|
||||
}
|
||||
|
||||
async fn list(&self, sort: SortField, order: SortOrder) -> Result<Vec<SongSummary>, RepositoryError> {
|
||||
let sql = format!(
|
||||
"SELECT id, title, artist, original_key, preview_chords, body FROM songs {}",
|
||||
sort_clause(sort, order)
|
||||
);
|
||||
let rows = sqlx::query_as::<_, SongRow>(&sql)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||
|
||||
rows.into_iter().map(row_to_summary).collect()
|
||||
}
|
||||
|
||||
async fn get(&self, id: Uuid) -> Result<Option<Song>, RepositoryError> {
|
||||
let id_str = id.to_string();
|
||||
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()))?;
|
||||
|
||||
match row {
|
||||
None => Ok(None),
|
||||
Some(r) => {
|
||||
let song: Song = serde_json::from_str(&r.body)
|
||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||
Ok(Some(song))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete(&self, id: Uuid) -> Result<(), RepositoryError> {
|
||||
let id_str = id.to_string();
|
||||
let result = sqlx::query("DELETE FROM songs WHERE id = ?")
|
||||
.bind(&id_str)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
Err(RepositoryError::NotFound)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
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)?;
|
||||
|
||||
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: Option<&str> = 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SqliteRepositoryFactory;
|
||||
|
||||
impl SqliteRepositoryFactory {
|
||||
pub async fn create(database_url: &str) -> Result<SqliteSongRepository, sqlx::Error> {
|
||||
SqliteSongRepository::new(database_url).await
|
||||
}
|
||||
}
|
||||
42
crates/infrastructure/persistence/src/row.rs
Normal file
42
crates/infrastructure/persistence/src/row.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
use domain::{RepositoryError, SongMeta, SongSummary, SortField, SortOrder};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(crate) struct SongRow {
|
||||
pub(crate) id: String,
|
||||
pub(crate) title: String,
|
||||
pub(crate) artist: String,
|
||||
pub(crate) original_key: Option<String>,
|
||||
pub(crate) preview_chords: String,
|
||||
pub(crate) body: String,
|
||||
}
|
||||
|
||||
pub(crate) fn sort_clause(field: SortField, order: SortOrder) -> &'static str {
|
||||
match (field, order) {
|
||||
(SortField::Title, SortOrder::Asc) => "ORDER BY title ASC",
|
||||
(SortField::Title, SortOrder::Desc) => "ORDER BY title DESC",
|
||||
(SortField::Artist, SortOrder::Asc) => "ORDER BY artist ASC",
|
||||
(SortField::Artist, SortOrder::Desc) => "ORDER BY artist DESC",
|
||||
(SortField::Date, SortOrder::Asc) => "ORDER BY created_at ASC",
|
||||
(SortField::Date, SortOrder::Desc) => "ORDER BY created_at DESC",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn row_to_summary(row: SongRow) -> Result<SongSummary, RepositoryError> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
26
crates/infrastructure/persistence/src/search.rs
Normal file
26
crates/infrastructure/persistence/src/search.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{RepositoryError, SongSearchPort, SongSummary, SortField, SortOrder};
|
||||
|
||||
use crate::repository::SqliteSongRepository;
|
||||
use crate::row::{SongRow, sort_clause, row_to_summary};
|
||||
|
||||
#[async_trait]
|
||||
impl SongSearchPort for SqliteSongRepository {
|
||||
async fn search(&self, query: &str, sort: SortField, order: SortOrder) -> Result<Vec<SongSummary>, RepositoryError> {
|
||||
let escaped = query.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
|
||||
let pattern = format!("%{}%", escaped);
|
||||
let sql = format!(
|
||||
"SELECT id, title, artist, original_key, preview_chords, body FROM songs \
|
||||
WHERE (title LIKE ? ESCAPE '\\' OR artist LIKE ? ESCAPE '\\') {}",
|
||||
sort_clause(sort, order)
|
||||
);
|
||||
let rows = sqlx::query_as::<_, SongRow>(&sql)
|
||||
.bind(&pattern)
|
||||
.bind(&pattern)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||
|
||||
rows.into_iter().map(row_to_summary).collect()
|
||||
}
|
||||
}
|
||||
46
docker-compose.yml
Normal file
46
docker-compose.yml
Normal file
@@ -0,0 +1,46 @@
|
||||
# PocketChords – homeserver deployment template
|
||||
#
|
||||
# Usage:
|
||||
# cp .env.compose .env.compose.local # fill in your values
|
||||
# docker compose --env-file .env.compose.local up -d --build
|
||||
#
|
||||
# VITE_API_URL is baked into the JS bundle at build time.
|
||||
# Set it to the URL your BROWSER (and SSR server) will use to reach the API.
|
||||
# On a LAN homeserver: http://192.168.x.x:8000
|
||||
# Behind a reverse proxy: https://pocketchords.example.com/api
|
||||
|
||||
services:
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${API_PORT:-8000}:8000"
|
||||
environment:
|
||||
DATABASE_URL: sqlite:///app/data/pocket-chords.db
|
||||
HOST: 0.0.0.0
|
||||
PORT: 8000
|
||||
# Comma-separated allowed origins, or * for any.
|
||||
# Lock this down in production: https://pocketchords.yourdomain.com
|
||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-*}
|
||||
volumes:
|
||||
- api-data:/app/data
|
||||
|
||||
app:
|
||||
build:
|
||||
context: ./app
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VITE_API_URL: ${VITE_API_URL:-http://localhost:8000}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${APP_PORT:-3000}:3000"
|
||||
environment:
|
||||
PORT: 3000
|
||||
depends_on:
|
||||
- api
|
||||
|
||||
volumes:
|
||||
api-data:
|
||||
driver: local
|
||||
1107
docs/superpowers/plans/2026-04-09-chord-diagram.md
Normal file
1107
docs/superpowers/plans/2026-04-09-chord-diagram.md
Normal file
File diff suppressed because it is too large
Load Diff
134
docs/superpowers/specs/2026-04-09-chord-diagram-design.md
Normal file
134
docs/superpowers/specs/2026-04-09-chord-diagram-design.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# 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