feat: GuitarFretboard component

This commit is contained in:
2026-04-09 00:39:43 +02:00
parent 7653933f46
commit 5a4d0ef648

View File

@@ -0,0 +1,103 @@
import type { GuitarVoicing } from '~/lib/chord-voicing';
const FRETS_SHOWN = 4;
const STRING_COUNT = 6;
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 h-20 w-24">
no voicing
</div>
);
}
const { frets, baseFret, barre } = voicing;
// Show fret number label when not at open position
const showFretLabel = baseFret > 0;
return (
<div className="flex flex-col items-center gap-1">
{/* Open/muted string indicators above nut */}
<div className="flex" style={{ gap: 2 }}>
{frets.map((f, i) => (
<div
key={i}
className="flex items-center justify-center text-xs font-mono"
style={{ width: 14 }}
>
{f === null ? (
<span className="text-destructive"></span>
) : f === 0 ? (
<span className="text-muted-foreground"></span>
) : null}
</div>
))}
</div>
{/* Fretboard grid */}
<div className="relative flex" style={{ gap: 0 }}>
{/* Fret number label */}
{showFretLabel && (
<div
className="absolute text-xs text-muted-foreground font-mono"
style={{ left: -18, top: 0, lineHeight: '18px' }}
>
{baseFret}
</div>
)}
{/* Strings (columns) */}
{frets.map((fret, stringIdx) => (
<div key={stringIdx} className="relative" style={{ width: 14 }}>
{/* Nut or top border */}
<div
style={{
height: showFretLabel ? 2 : 4,
background: showFretLabel ? 'hsl(var(--border))' : 'hsl(var(--foreground))',
margin: '0 2px',
}}
/>
{/* Fret cells */}
{Array.from({ length: FRETS_SHOWN }, (_, fretIdx) => {
// Open position (baseFret=0): rows represent frets 1-4 (nut is shown above)
// Barre position (baseFret>0): rows represent frets baseFret, baseFret+1, …
const absoluteFret = baseFret === 0 ? fretIdx + 1 : baseFret + fretIdx;
const hasDot = fret !== null && fret > 0 && fret === absoluteFret;
return (
<div
key={fretIdx}
className="flex items-center justify-center border-b border-border"
style={{ height: 18, borderLeft: stringIdx === 0 ? '1px solid hsl(var(--border))' : undefined, borderRight: '1px solid hsl(var(--border))' }}
>
{hasDot && (
<div className="w-3 h-3 rounded-full bg-primary z-10" />
)}
</div>
);
})}
</div>
))}
{/* Barre indicator — only for actual barre chords (not open position) */}
{barre !== null && barre > 0 && (
<div
className="absolute rounded-full bg-primary"
style={{
left: 2,
right: 2,
height: 8,
top: (showFretLabel ? 2 : 4) + (barre - baseFret) * 18 + 5,
zIndex: 5,
}}
/>
)}
</div>
</div>
);
}