Files
pocket-chords/app/app/routes/songs.$id.tsx

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>
);
}