feat: chord diagram — piano/guitar diagrams in song detail
This commit is contained in:
@@ -1,11 +1,15 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useRef, useCallback } from "react";
|
||||||
import { data, Link } from "react-router";
|
import { data, Link } from "react-router";
|
||||||
import type { Route } from "./+types/songs.$id";
|
import type { Route } from "./+types/songs.$id";
|
||||||
import { TransposeBar } from "~/components/transpose-bar";
|
import { TransposeBar } from "~/components/transpose-bar";
|
||||||
import { ChordChart } from "~/components/chord-chart";
|
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 { EditSongSheet } from "~/components/edit-song-sheet";
|
||||||
import { DeleteSongDialog } from "~/components/delete-song-dialog";
|
import { DeleteSongDialog } from "~/components/delete-song-dialog";
|
||||||
import { transposeSong } from "~/lib/transpose";
|
import { transposeSong } from "~/lib/transpose";
|
||||||
|
import { extractUniqueChords } from "~/lib/song-utils";
|
||||||
import { getSong } from "~/lib/api";
|
import { getSong } from "~/lib/api";
|
||||||
import type { Song, SongSummary } from "~/lib/types";
|
import type { Song, SongSummary } from "~/lib/types";
|
||||||
|
|
||||||
@@ -41,6 +45,14 @@ function initFontSize(): FontSize {
|
|||||||
return 'sm';
|
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) {
|
export default function SongDetail({ loaderData }: Route.ComponentProps) {
|
||||||
const { song: initialSong, id } = loaderData;
|
const { song: initialSong, id } = loaderData;
|
||||||
const [baseSong, setBaseSong] = useState<Song | null>(initialSong ?? null);
|
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 [fontSize, setFontSize] = useState<FontSize>(initFontSize);
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [deleteOpen, setDeleteOpen] = 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(() => {
|
useEffect(() => {
|
||||||
if (applyCapo && baseSong?.meta.capo) {
|
if (applyCapo && baseSong?.meta.capo) {
|
||||||
@@ -81,6 +96,13 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
|
|||||||
try { localStorage.setItem('fontSize', size); } catch { /* noop */ }
|
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) {
|
if (!baseSong || !displayedSong) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-full gap-4">
|
<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 displayed = transposeSong(displayedSong, offset);
|
||||||
|
const uniqueChords = extractUniqueChords(displayed.sections);
|
||||||
|
const handleChordClick = (chord: string) => setActiveChord(chord);
|
||||||
|
|
||||||
function handleUpdated(summary: SongSummary) {
|
function handleUpdated(summary: SongSummary) {
|
||||||
setBaseSong((prev) => prev ? { ...prev, meta: summary.meta } : prev);
|
setBaseSong((prev) => prev ? { ...prev, meta: summary.meta } : prev);
|
||||||
@@ -100,7 +124,7 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full max-w-lg mx-auto">
|
<div className="flex flex-col h-full">
|
||||||
<TransposeBar
|
<TransposeBar
|
||||||
meta={baseSong.meta}
|
meta={baseSong.meta}
|
||||||
offset={offset}
|
offset={offset}
|
||||||
@@ -113,9 +137,56 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
|
|||||||
applyCapo={applyCapo}
|
applyCapo={applyCapo}
|
||||||
onToggleCapo={() => setApplyCapo((v) => !v)}
|
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>
|
</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
|
<EditSongSheet
|
||||||
id={id}
|
id={id}
|
||||||
meta={baseSong.meta}
|
meta={baseSong.meta}
|
||||||
|
|||||||
Reference in New Issue
Block a user