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 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}
|
||||
|
||||
Reference in New Issue
Block a user