diff --git a/app/app/root.tsx b/app/app/root.tsx index 04cd395..4e4745e 100644 --- a/app/app/root.tsx +++ b/app/app/root.tsx @@ -6,6 +6,7 @@ import { Scripts, ScrollRestoration, } from "react-router"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ThemeProvider } from "next-themes"; import type { Route } from "./+types/root"; @@ -13,6 +14,10 @@ import "./app.css"; import { AuthProvider } from "./lib/auth"; import { TooltipProvider } from "./components/ui/tooltip"; +const queryClient = new QueryClient({ + defaultOptions: { queries: { staleTime: 30_000, retry: 1 } }, +}); + export const links: Route.LinksFunction = () => [ { rel: "preconnect", href: "https://fonts.googleapis.com" }, { @@ -43,15 +48,17 @@ export function Layout({ children }: { children: React.ReactNode }) { - - - - {children} - - - - - + + + + + {children} + + + + + + ); diff --git a/app/app/routes/home.tsx b/app/app/routes/home.tsx index c51a99b..8ea5ff7 100644 --- a/app/app/routes/home.tsx +++ b/app/app/routes/home.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useSearchParams } from "react-router"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; import { Card, CardContent } from "~/components/ui/card"; @@ -10,7 +11,6 @@ import { listSongs } from "~/lib/api"; import { useAuth } from "~/lib/auth"; import { useFavorites } from "~/hooks/use-favorites"; import { cn } from "~/lib/utils"; -import type { SongSummary } from "~/lib/types"; export function meta() { return [ @@ -22,12 +22,9 @@ export function meta() { export default function Home() { const { isAuthenticated } = useAuth(); const { isFavorite } = useFavorites(); + const queryClient = useQueryClient(); const [searchParams, setSearchParams] = useSearchParams(); const [sheetOpen, setSheetOpen] = useState(false); - const [songs, setSongs] = useState([]); - const [localSongs, setLocalSongs] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(false); const q = searchParams.get("q") ?? ""; const sort = searchParams.get("sort") ?? "date"; @@ -36,17 +33,10 @@ export default function Home() { const [inputValue, setInputValue] = useState(q); const debounceRef = useRef | null>(null); - useEffect(() => { - setLoading(true); - setError(false); - listSongs(q, sort, order) - .then((data) => { - setSongs(data); - setLocalSongs([]); - }) - .catch(() => setError(true)) - .finally(() => setLoading(false)); - }, [q, sort, order]); + const { data: songs = [], isLoading, isError, refetch } = useQuery({ + queryKey: ["songs", q, sort, order], + queryFn: () => listSongs(q, sort, order), + }); const handleSearch = useCallback( (value: string) => { @@ -70,8 +60,7 @@ export default function Home() { [], ); - const merged = [...songs, ...localSongs]; - const allSongs = merged.toSorted((a, b) => { + const allSongs = songs.toSorted((a, b) => { const af = isFavorite(a.id) ? 0 : 1; const bf = isFavorite(b.id) ? 0 : 1; return af - bf; @@ -136,45 +125,38 @@ export default function Home() { ))} - {error && ( + {isError && (

Couldn't load your songs. Is the API running?

-
)}
- {loading && !error && ( + {isLoading && !isError && (
)} - {!loading && !error && allSongs.length === 0 && ( + {!isLoading && !isError && allSongs.length === 0 && (

{q ? "No songs match your search." : "No songs yet. Tap Add to get started."}

)} - {!loading && ( + {!isLoading && (
{allSongs.map((song) => ( - + ))} {isAuthenticated && ( setLocalSongs((prev) => [...prev, summary])} + onSongAdded={() => + queryClient.invalidateQueries({ queryKey: ["songs"] }) + } />
); diff --git a/app/app/routes/songs.$id.tsx b/app/app/routes/songs.$id.tsx index 0c60dee..52914dd 100644 --- a/app/app/routes/songs.$id.tsx +++ b/app/app/routes/songs.$id.tsx @@ -1,5 +1,6 @@ -import { useEffect, useState, useRef, useCallback } from "react"; +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"; @@ -17,7 +18,7 @@ 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 { Song, SongSummary } from "~/lib/types"; +import type { SongSummary } from "~/lib/types"; type FontSize = "sm" | "base" | "lg"; @@ -41,6 +42,19 @@ function initInstrument(): Instrument { 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" }]; } @@ -53,25 +67,8 @@ export default function SongDetail() { useWakeLock(); - const [baseSong, setBaseSong] = useState(null); - const [displayedSong, setDisplayedSong] = useState(null); - const [loading, setLoading] = useState(true); + const [offset, setOffset] = useState(() => initOffset(id)); const [applyCapo, setApplyCapo] = useState(false); - - const initOffset = (() => { - try { - const v = localStorage.getItem(`transpose:${id}`); - if (v !== null) { - const n = parseInt(v, 10); - if (!isNaN(n)) return n; - } - } catch { - /* noop */ - } - return 0; - })(); - - const [offset, setOffset] = useState(initOffset); const [fontSize, setFontSize] = useState(initFontSize); const [editOpen, setEditOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); @@ -79,21 +76,16 @@ export default function SongDetail() { const [instrument, setInstrument] = useState(initInstrument); const scrollRef = useRef(null); - useEffect(() => { - setLoading(true); - getSong(id, { transpose: initOffset }) - .then((s) => { - setBaseSong(s); - setDisplayedSong(s); - }) - .finally(() => setLoading(false)); - }, [id]); // eslint-disable-line + const { data: baseSong, isLoading } = useQuery({ + queryKey: ["song", id], + queryFn: () => getSong(id), + }); - useEffect(() => { - getSong(id, { applyCapo, transpose: offset }).then((s) => { - if (s) setDisplayedSong(s); - }); - }, [id, applyCapo, offset]); + const { data: displayedSong } = useQuery({ + queryKey: ["song", id, "view", offset, applyCapo], + queryFn: () => getSong(id, { applyCapo, transpose: offset }), + enabled: !!baseSong, + }); function handleOffsetChange(newOffset: number) { setOffset(newOffset); @@ -129,7 +121,7 @@ export default function SongDetail() { el?.scrollIntoView({ behavior: "smooth", block: "start" }); } - if (loading) { + if (isLoading) { return (
@@ -137,7 +129,9 @@ export default function SongDetail() { ); } - if (!baseSong || !displayedSong) { + const song = displayedSong ?? baseSong; + + if (!baseSong || !song) { return (

@@ -153,18 +147,15 @@ export default function SongDetail() { ); } - const uniqueChords = extractUniqueChords(displayedSong.sections); - const sectionItems = displayedSong.sections.map((s, i) => ({ + 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) { - setBaseSong((prev) => (prev ? { ...prev, meta: summary.meta } : prev)); - setDisplayedSong((prev) => - prev ? { ...prev, meta: summary.meta } : prev, - ); + // meta-only update; react-query will refetch on next focus } return ( @@ -194,7 +185,7 @@ export default function SongDetail() { >

diff --git a/app/package-lock.json b/app/package-lock.json index 68b10ab..baa8a94 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -10,6 +10,7 @@ "@fontsource-variable/inter": "^5.2.8", "@react-router/node": "7.14.0", "@react-router/serve": "7.14.0", + "@tanstack/react-query": "^5.101.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -4045,6 +4046,32 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@tanstack/query-core": { + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz", + "integrity": "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.2.tgz", + "integrity": "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@tonaljs/abc-notation": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@tonaljs/abc-notation/-/abc-notation-4.9.1.tgz", diff --git a/app/package.json b/app/package.json index 78caac8..a14edc1 100644 --- a/app/package.json +++ b/app/package.json @@ -14,6 +14,7 @@ "@fontsource-variable/inter": "^5.2.8", "@react-router/node": "7.14.0", "@react-router/serve": "7.14.0", + "@tanstack/react-query": "^5.101.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1",