feat: chord diagram — piano/guitar diagrams in song detail

This commit is contained in:
2026-04-09 00:44:32 +02:00
parent e99b581a43
commit e9c2f7a5e0

View File

@@ -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}