247 lines
7.4 KiB
TypeScript
247 lines
7.4 KiB
TypeScript
import { useState, useRef, useCallback } from "react";
|
|
import { Link, useParams } from "react-router";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { Loader2 } from "lucide-react";
|
|
import { Button } from "~/components/ui/button";
|
|
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 { SectionNav } from "~/components/section-nav";
|
|
import { AutoScrollControls } from "~/components/auto-scroll-controls";
|
|
import { extractUniqueChords } from "~/lib/song-utils";
|
|
import { getSong } from "~/lib/api";
|
|
import { useAuth } from "~/lib/auth";
|
|
import { useFavorites } from "~/hooks/use-favorites";
|
|
import { useFullscreen } from "~/hooks/use-fullscreen";
|
|
import { useWakeLock } from "~/hooks/use-wake-lock";
|
|
import type { SongSummary } from "~/lib/types";
|
|
|
|
type FontSize = "sm" | "base" | "lg";
|
|
|
|
function initFontSize(): FontSize {
|
|
try {
|
|
const v = localStorage.getItem("fontSize");
|
|
if (v === "sm" || v === "base" || v === "lg") return v;
|
|
} catch {
|
|
/* noop */
|
|
}
|
|
return "sm";
|
|
}
|
|
|
|
function initInstrument(): Instrument {
|
|
try {
|
|
const v = localStorage.getItem("chordDiagramInstrument");
|
|
if (v === "piano" || v === "guitar") return v;
|
|
} catch {
|
|
/* noop */
|
|
}
|
|
return "piano";
|
|
}
|
|
|
|
function initOffset(id: string): number {
|
|
try {
|
|
const v = localStorage.getItem(`transpose:${id}`);
|
|
if (v !== null) {
|
|
const n = parseInt(v, 10);
|
|
if (!isNaN(n)) return n;
|
|
}
|
|
} catch {
|
|
/* noop */
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
export function meta() {
|
|
return [{ title: "PocketChords" }];
|
|
}
|
|
|
|
export default function SongDetail() {
|
|
const { id = "" } = useParams();
|
|
const { isAuthenticated } = useAuth();
|
|
const { isFavorite, toggle: toggleFavorite } = useFavorites();
|
|
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen();
|
|
|
|
useWakeLock();
|
|
|
|
const [offset, setOffset] = useState(() => initOffset(id));
|
|
const [applyCapo, setApplyCapo] = useState(false);
|
|
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);
|
|
|
|
const { data: baseSong, isLoading } = useQuery({
|
|
queryKey: ["song", id],
|
|
queryFn: () => getSong(id),
|
|
});
|
|
|
|
const { data: displayedSong } = useQuery({
|
|
queryKey: ["song", id, "view", offset, applyCapo],
|
|
queryFn: () => getSong(id, { applyCapo, transpose: offset }),
|
|
enabled: !!baseSong,
|
|
});
|
|
|
|
function handleOffsetChange(newOffset: number) {
|
|
setOffset(newOffset);
|
|
try {
|
|
localStorage.setItem(`transpose:${id}`, String(newOffset));
|
|
} catch {
|
|
/* noop */
|
|
}
|
|
}
|
|
|
|
function handleFontSizeChange(size: FontSize) {
|
|
setFontSize(size);
|
|
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), []);
|
|
|
|
function handleSectionJump(index: number) {
|
|
const el = document.getElementById(`section-${index}`);
|
|
el?.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
}
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-full">
|
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const song = displayedSong ?? baseSong;
|
|
|
|
if (!baseSong || !song) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center h-full gap-4">
|
|
<p className="text-muted-foreground text-sm">
|
|
Song not found or unavailable.
|
|
</p>
|
|
<Link
|
|
to="/"
|
|
className="text-sm text-primary underline-offset-4 hover:underline"
|
|
>
|
|
← Back to library
|
|
</Link>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const uniqueChords = extractUniqueChords(song.sections);
|
|
const sectionItems = song.sections.map((s, i) => ({
|
|
label: s.label,
|
|
index: i,
|
|
}));
|
|
const handleChordClick = (chord: string) => setActiveChord(chord);
|
|
|
|
function handleUpdated(summary: SongSummary) {
|
|
// meta-only update; react-query will refetch on next focus
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
<TransposeBar
|
|
meta={baseSong.meta}
|
|
offset={offset}
|
|
onOffsetChange={handleOffsetChange}
|
|
onEdit={isAuthenticated ? () => setEditOpen(true) : undefined}
|
|
onDelete={isAuthenticated ? () => setDeleteOpen(true) : undefined}
|
|
fontSize={fontSize}
|
|
onFontSizeChange={handleFontSizeChange}
|
|
capo={baseSong.meta.capo ?? undefined}
|
|
applyCapo={applyCapo}
|
|
onToggleCapo={() => setApplyCapo((v) => !v)}
|
|
isFavorite={isFavorite(id)}
|
|
onToggleFavorite={() => toggleFavorite(id)}
|
|
fullscreen={isFullscreen}
|
|
onToggleFullscreen={toggleFullscreen}
|
|
/>
|
|
|
|
<div className="flex-1 overflow-hidden flex flex-col lg:flex-row">
|
|
<div
|
|
className="flex-1 overflow-y-auto"
|
|
ref={scrollRef}
|
|
onScroll={handleScroll}
|
|
>
|
|
<div className="max-w-lg mx-auto lg:max-w-none">
|
|
<ChordChart
|
|
sections={song.sections}
|
|
fontSize={fontSize}
|
|
onChordClick={handleChordClick}
|
|
/>
|
|
|
|
<div className="lg:hidden border-t border-border">
|
|
<ChordGrid
|
|
chords={uniqueChords}
|
|
instrument={instrument}
|
|
onInstrumentChange={handleInstrumentChange}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<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 className="lg:hidden border-t border-border bg-background flex items-center">
|
|
<SectionNav sections={sectionItems} onJump={handleSectionJump} />
|
|
<AutoScrollControls scrollRef={scrollRef} />
|
|
</div>
|
|
|
|
{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
|
|
variant="ghost"
|
|
size="sm"
|
|
className="ml-auto text-xs text-muted-foreground"
|
|
onClick={() => setActiveChord(null)}
|
|
>
|
|
close
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
<EditSongSheet
|
|
id={id}
|
|
meta={baseSong.meta}
|
|
open={editOpen}
|
|
onOpenChange={setEditOpen}
|
|
onUpdated={handleUpdated}
|
|
/>
|
|
<DeleteSongDialog
|
|
id={id}
|
|
title={baseSong.meta.title}
|
|
open={deleteOpen}
|
|
onOpenChange={setDeleteOpen}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|