Compare commits

..

7 Commits

18 changed files with 607 additions and 179 deletions

View File

@@ -0,0 +1,114 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Pause, Play } from "lucide-react";
import { Button } from "~/components/ui/button";
import { Slider } from "~/components/ui/slider";
interface Props {
scrollRef: React.RefObject<HTMLDivElement | null>;
}
function loadSpeed(): number {
try {
const v = localStorage.getItem("autoScrollSpeed");
if (v) return parseFloat(v);
} catch {
/* noop */
}
return 30;
}
export function AutoScrollControls({ scrollRef }: Props) {
const [playing, setPlaying] = useState(false);
const [speed, setSpeed] = useState(loadSpeed);
const rafRef = useRef<number>(0);
const lastTimeRef = useRef<number>(0);
const cancelledByUser = useRef(false);
const accumulatorRef = useRef(0);
const tick = useCallback(
(time: number) => {
const el = scrollRef.current;
if (!el) return;
if (lastTimeRef.current) {
const dt = (time - lastTimeRef.current) / 1000;
accumulatorRef.current += speed * dt;
const px = Math.floor(accumulatorRef.current);
if (px >= 1) {
accumulatorRef.current -= px;
el.scrollBy({ top: px });
}
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 1) {
setPlaying(false);
return;
}
}
lastTimeRef.current = time;
rafRef.current = requestAnimationFrame(tick);
},
[scrollRef, speed],
);
useEffect(() => {
if (playing) {
lastTimeRef.current = 0;
accumulatorRef.current = 0;
cancelledByUser.current = false;
rafRef.current = requestAnimationFrame(tick);
} else {
cancelAnimationFrame(rafRef.current);
}
return () => cancelAnimationFrame(rafRef.current);
}, [playing, tick]);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
function handleUserScroll() {
if (playing && !cancelledByUser.current) {
cancelledByUser.current = true;
setPlaying(false);
}
}
el.addEventListener("touchstart", handleUserScroll, { passive: true });
return () => el.removeEventListener("touchstart", handleUserScroll);
}, [scrollRef, playing]);
function handleSpeedChange(value: number[]) {
const v = value[0];
setSpeed(v);
try {
localStorage.setItem("autoScrollSpeed", String(v));
} catch {
/* noop */
}
}
return (
<div className="flex items-center gap-2 px-3 py-1 shrink-0">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => setPlaying((p) => !p)}
>
{playing ? (
<Pause className="w-3.5 h-3.5" />
) : (
<Play className="w-3.5 h-3.5" />
)}
</Button>
<Slider
min={10}
max={80}
step={5}
value={[speed]}
onValueChange={handleSpeedChange}
className="w-24"
/>
</div>
);
}

View File

@@ -45,7 +45,7 @@ function ChordRow({
onChordClick?: (chord: string) => void; onChordClick?: (chord: string) => void;
}) { }) {
return ( return (
<div className={`relative font-mono ${sizeClass} text-primary`} style={{ height: '1.5em' }}> <div className={`relative font-mono ${sizeClass} text-primary overflow-hidden`} style={{ height: '1.5em' }}>
{chords.map(({ offset, chord }, i) => ( {chords.map(({ offset, chord }, i) => (
<span <span
key={i} key={i}
@@ -85,15 +85,17 @@ function LineBlock({
function SectionBlock({ function SectionBlock({
section, section,
index,
sizeClass, sizeClass,
onChordClick, onChordClick,
}: { }: {
section: Section; section: Section;
index: number;
sizeClass: string; sizeClass: string;
onChordClick?: (chord: string) => void; onChordClick?: (chord: string) => void;
}) { }) {
return ( return (
<div className="mb-6"> <div className="mb-6" id={`section-${index}`}>
{section.label && ( {section.label && (
<p className="text-xs text-muted-foreground mb-1">[{section.label}]</p> <p className="text-xs text-muted-foreground mb-1">[{section.label}]</p>
)} )}
@@ -111,7 +113,7 @@ export function ChordChart({ sections, fontSize, onChordClick }: Props) {
return ( return (
<div className="px-4 py-3"> <div className="px-4 py-3">
{sections.map((section, i) => ( {sections.map((section, i) => (
<SectionBlock key={i} section={section} sizeClass={sizeClass} onChordClick={onChordClick} /> <SectionBlock key={i} section={section} index={i} sizeClass={sizeClass} onChordClick={onChordClick} />
))} ))}
</div> </div>
); );

View File

@@ -0,0 +1,46 @@
import { Button } from "~/components/ui/button";
import { ScrollArea, ScrollBar } from "~/components/ui/scroll-area";
interface Props {
sections: Array<{ label: string | null; index: number }>;
onJump: (index: number) => void;
}
function abbreviate(label: string): string {
const lower = label.toLowerCase().replace(/[-_ ]/g, "");
if (lower.startsWith("verse")) return label.replace(/verse\s*/i, "V");
if (lower.startsWith("chorus") || lower.startsWith("refrain")) return "C";
if (lower.startsWith("bridge")) return "Br";
if (lower.startsWith("prechorus")) return "PC";
if (lower.startsWith("intro")) return "In";
if (lower.startsWith("outro")) return "Out";
if (lower.startsWith("instrumental") || lower.startsWith("interlude"))
return "Int";
if (lower.startsWith("solo")) return "Sol";
if (label.length > 4) return label.slice(0, 3);
return label;
}
export function SectionNav({ sections, onJump }: Props) {
const labeled = sections.filter((s) => s.label);
if (labeled.length === 0) return null;
return (
<ScrollArea className="min-w-0 flex-1">
<div className="flex gap-1 px-3 py-1.5">
{labeled.map((s) => (
<Button
key={s.index}
variant="secondary"
size="sm"
className="h-6 px-2 text-xs shrink-0"
onClick={() => onJump(s.index)}
>
{abbreviate(s.label!)}
</Button>
))}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
);
}

View File

@@ -1,16 +1,21 @@
import { Link } from "react-router"; import { Link } from "react-router";
import { Star } from "lucide-react";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { Card, CardContent } from "~/components/ui/card"; import { Card, CardContent } from "~/components/ui/card";
import type { SongSummary } from "~/lib/types"; import type { SongSummary } from "~/lib/types";
interface Props { interface Props {
song: SongSummary; song: SongSummary;
isFavorite?: boolean;
} }
export function SongCard({ song }: Props) { export function SongCard({ song, isFavorite }: Props) {
return ( return (
<Link to={`/songs/${song.id}`}> <Link to={`/songs/${song.id}`}>
<Card className="h-full hover:bg-accent transition-colors cursor-pointer"> <Card className="h-full hover:bg-accent transition-colors cursor-pointer relative">
{isFavorite && (
<Star className="absolute top-2 right-2 w-3.5 h-3.5 fill-primary text-primary" />
)}
<CardContent className="p-3 flex flex-col gap-1"> <CardContent className="p-3 flex flex-col gap-1">
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{song.preview_chords.map((chord) => ( {song.preview_chords.map((chord) => (

View File

@@ -1,9 +1,23 @@
import { useState } from "react"; import { useState } from "react";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu"; } from "~/components/ui/dropdown-menu";
import { ChevronUp, ChevronDown, Minus, Plus, MoreHorizontal, Pencil, Trash2 } from "lucide-react"; import {
ChevronUp,
ChevronDown,
Maximize,
Minus,
Minimize,
MoreHorizontal,
Pencil,
Plus,
Star,
Trash2,
} from "lucide-react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import type { SongMeta } from "~/lib/types"; import type { SongMeta } from "~/lib/types";
@@ -13,41 +27,100 @@ interface Props {
onOffsetChange: (offset: number) => void; onOffsetChange: (offset: number) => void;
onEdit?: () => void; onEdit?: () => void;
onDelete?: () => void; onDelete?: () => void;
fontSize?: 'sm' | 'base' | 'lg'; fontSize?: "sm" | "base" | "lg";
onFontSizeChange?: (size: 'sm' | 'base' | 'lg') => void; onFontSizeChange?: (size: "sm" | "base" | "lg") => void;
capo?: number; capo?: number;
applyCapo?: boolean; applyCapo?: boolean;
onToggleCapo?: () => void; onToggleCapo?: () => void;
isFavorite?: boolean;
onToggleFavorite?: () => void;
fullscreen?: boolean;
onToggleFullscreen?: () => void;
} }
export function TransposeBar({ meta, offset, onOffsetChange, onEdit, onDelete, fontSize, onFontSizeChange, capo, applyCapo, onToggleCapo }: Props) { const NOTES_SHARP = ["C","C#","D","D#","E","F","F#","G","G#","A","A#","B"];
const NOTES_FLAT = ["C","Db","D","Eb","E","F","Gb","G","Ab","A","Bb","B"];
function transposedKey(key: string, offset: number): string {
const root = key.length >= 2 && (key[1] === "#" || key[1] === "b") ? key.slice(0, 2) : key.slice(0, 1);
const suffix = key.slice(root.length);
const notes = offset > 0 ? NOTES_SHARP : NOTES_FLAT;
const idx = NOTES_SHARP.indexOf(root) !== -1 ? NOTES_SHARP.indexOf(root) : NOTES_FLAT.indexOf(root);
if (idx === -1) return key;
const newIdx = ((idx + offset) % 12 + 12) % 12;
return notes[newIdx] + suffix;
}
export function TransposeBar({
meta,
offset,
onOffsetChange,
onEdit,
onDelete,
fontSize,
onFontSizeChange,
capo,
applyCapo,
onToggleCapo,
isFavorite,
onToggleFavorite,
fullscreen,
onToggleFullscreen,
}: Props) {
const [expanded, setExpanded] = useState(true); const [expanded, setExpanded] = useState(true);
const label = offset === 0 ? "±0" : offset > 0 ? `+${offset}` : `${offset}`; const label = offset === 0 ? "±0" : offset > 0 ? `+${offset}` : `${offset}`;
const menuButton = (onEdit || onDelete) ? ( const keyDisplay =
<DropdownMenu> meta.original_key && offset !== 0
<DropdownMenuTrigger asChild> ? `Key: ${meta.original_key}${transposedKey(meta.original_key, offset)}`
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0"> : meta.original_key
<MoreHorizontal className="w-4 h-4" /> ? `Key: ${meta.original_key}`
: null;
const menuButton =
onEdit || onDelete ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0">
<MoreHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{onEdit && (
<DropdownMenuItem onClick={onEdit}>
<Pencil className="w-4 h-4 mr-2" />
Edit
</DropdownMenuItem>
)}
{onDelete && (
<DropdownMenuItem
onClick={onDelete}
className="text-destructive focus:text-destructive"
>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
) : null;
if (fullscreen) {
return (
<div className="flex items-center justify-between px-4 py-1.5 border-b bg-background">
<span className="text-sm font-semibold truncate">{meta.title}</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onToggleFullscreen}
>
<Minimize className="w-4 h-4" />
</Button> </Button>
</DropdownMenuTrigger> </div>
<DropdownMenuContent align="end"> );
{onEdit && ( }
<DropdownMenuItem onClick={onEdit}>
<Pencil className="w-4 h-4 mr-2" />
Edit
</DropdownMenuItem>
)}
{onDelete && (
<DropdownMenuItem onClick={onDelete} className="text-destructive focus:text-destructive">
<Trash2 className="w-4 h-4 mr-2" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
) : null;
if (!expanded) { if (!expanded) {
return ( return (
@@ -55,7 +128,12 @@ export function TransposeBar({ meta, offset, onOffsetChange, onEdit, onDelete, f
<span className="text-sm font-semibold truncate">{meta.title}</span> <span className="text-sm font-semibold truncate">{meta.title}</span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{menuButton} {menuButton}
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setExpanded(true)}> <Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setExpanded(true)}
>
<ChevronDown className="w-4 h-4" /> <ChevronDown className="w-4 h-4" />
</Button> </Button>
</div> </div>
@@ -71,8 +149,40 @@ export function TransposeBar({ meta, offset, onOffsetChange, onEdit, onDelete, f
<span className="text-sm text-muted-foreground">{meta.artist}</span> <span className="text-sm text-muted-foreground">{meta.artist}</span>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{onToggleFavorite && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={onToggleFavorite}
>
<Star
className={cn(
"w-4 h-4",
isFavorite
? "fill-primary text-primary"
: "text-muted-foreground",
)}
/>
</Button>
)}
{onToggleFullscreen && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={onToggleFullscreen}
>
<Maximize className="w-4 h-4" />
</Button>
)}
{menuButton} {menuButton}
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={() => setExpanded(false)}> <Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => setExpanded(false)}
>
<ChevronUp className="w-4 h-4" /> <ChevronUp className="w-4 h-4" />
</Button> </Button>
</div> </div>
@@ -80,16 +190,19 @@ export function TransposeBar({ meta, offset, onOffsetChange, onEdit, onDelete, f
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex gap-3 text-xs text-muted-foreground"> <div className="flex gap-3 text-xs text-muted-foreground">
{meta.original_key && <span>Key: {meta.original_key}</span>} {keyDisplay && <span>{keyDisplay}</span>}
{capo != null && onToggleCapo ? ( {capo != null && onToggleCapo ? (
<button <button
onClick={onToggleCapo} onClick={onToggleCapo}
className={cn( className={cn(
"text-xs transition-colors", "text-xs transition-colors",
applyCapo ? "text-primary" : "text-muted-foreground hover:text-foreground" applyCapo
? "text-primary"
: "text-muted-foreground hover:text-foreground",
)} )}
> >
Capo {capo}{applyCapo ? " · sounding" : ""} Capo {capo}
{applyCapo ? " · sounding" : ""}
</button> </button>
) : meta.capo != null ? ( ) : meta.capo != null ? (
<span>Capo: {meta.capo}</span> <span>Capo: {meta.capo}</span>
@@ -99,7 +212,7 @@ export function TransposeBar({ meta, offset, onOffsetChange, onEdit, onDelete, f
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{onFontSizeChange && ( {onFontSizeChange && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{(['sm', 'base', 'lg'] as const).map((s) => ( {(["sm", "base", "lg"] as const).map((s) => (
<button <button
key={s} key={s}
onClick={() => onFontSizeChange(s)} onClick={() => onFontSizeChange(s)}
@@ -107,21 +220,31 @@ export function TransposeBar({ meta, offset, onOffsetChange, onEdit, onDelete, f
"text-xs px-1.5 py-0.5 rounded transition-colors", "text-xs px-1.5 py-0.5 rounded transition-colors",
fontSize === s fontSize === s
? "bg-primary text-primary-foreground" ? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground" : "text-muted-foreground hover:text-foreground",
)} )}
> >
{s === 'sm' ? 'S' : s === 'base' ? 'M' : 'L'} {s === "sm" ? "S" : s === "base" ? "M" : "L"}
</button> </button>
))} ))}
</div> </div>
)} )}
<Button variant="ghost" size="icon" className="h-8 w-8" <Button
onClick={() => onOffsetChange(Math.max(-11, offset - 1))}> variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onOffsetChange(Math.max(-11, offset - 1))}
>
<Minus className="w-4 h-4" /> <Minus className="w-4 h-4" />
</Button> </Button>
<span className="w-8 text-center text-sm font-mono font-semibold">{label}</span> <span className="w-8 text-center text-sm font-mono font-semibold">
<Button variant="ghost" size="icon" className="h-8 w-8" {label}
onClick={() => onOffsetChange(Math.min(11, offset + 1))}> </span>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onOffsetChange(Math.min(11, offset + 1))}
>
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
</Button> </Button>
</div> </div>

View File

@@ -0,0 +1,57 @@
import { useCallback, useSyncExternalStore } from "react";
const STORAGE_KEY = "favorites";
let listeners: Array<() => void> = [];
let cachedRaw: string | null = null;
let cachedParsed: string[] = [];
function getSnapshot(): string[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw !== cachedRaw) {
cachedRaw = raw;
cachedParsed = raw ? JSON.parse(raw) : [];
}
} catch {
cachedParsed = [];
}
return cachedParsed;
}
function subscribe(cb: () => void) {
listeners.push(cb);
return () => {
listeners = listeners.filter((l) => l !== cb);
};
}
const EMPTY: string[] = [];
function getServerSnapshot(): string[] {
return EMPTY;
}
function notify() {
cachedRaw = null;
listeners.forEach((l) => l());
}
export function useFavorites() {
const favorites = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const isFavorite = useCallback(
(id: string) => favorites.includes(id),
[favorites],
);
const toggle = useCallback((id: string) => {
const current = getSnapshot();
const next = current.includes(id)
? current.filter((f) => f !== id)
: [...current, id];
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
notify();
}, []);
return { favorites, isFavorite, toggle };
}

View File

@@ -0,0 +1,22 @@
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
interface FullscreenState {
isFullscreen: boolean;
toggle: () => void;
}
const FullscreenContext = createContext<FullscreenState>({
isFullscreen: false,
toggle: () => {},
});
export function FullscreenProvider({ children }: { children: ReactNode }) {
const [isFullscreen, setIsFullscreen] = useState(false);
const toggle = useCallback(() => setIsFullscreen((v) => !v), []);
const value = useMemo(() => ({ isFullscreen, toggle }), [isFullscreen, toggle]);
return <FullscreenContext value={value}>{children}</FullscreenContext>;
}
export function useFullscreen() {
return useContext(FullscreenContext);
}

View File

@@ -0,0 +1,28 @@
import { useEffect, useRef } from "react";
export function useWakeLock() {
const lockRef = useRef<WakeLockSentinel | null>(null);
useEffect(() => {
async function acquire() {
try {
lockRef.current = await navigator.wakeLock.request("screen");
} catch {
// not supported or denied
}
}
acquire();
function handleVisibility() {
if (document.visibilityState === "visible") acquire();
}
document.addEventListener("visibilitychange", handleVisibility);
return () => {
document.removeEventListener("visibilitychange", handleVisibility);
lockRef.current?.release().catch(() => {});
};
}, []);
}

View File

@@ -94,9 +94,16 @@ export async function listSongs(q = "", sort = "date", order = "desc"): Promise<
return res.json(); return res.json();
} }
export async function getSong(id: string, applyCapo = false): Promise<Song | null> { export async function getSong(
const url = applyCapo id: string,
? `${API_BASE}/songs/${id}?apply_capo=true` opts: { applyCapo?: boolean; transpose?: number } = {},
): Promise<Song | null> {
const params = new URLSearchParams();
if (opts.applyCapo) params.set("apply_capo", "true");
if (opts.transpose && opts.transpose !== 0)
params.set("transpose", String(opts.transpose));
const url = params.size
? `${API_BASE}/songs/${id}?${params}`
: `${API_BASE}/songs/${id}`; : `${API_BASE}/songs/${id}`;
const res = await fetch(url); const res = await fetch(url);
if (res.status === 404) return null; if (res.status === 404) return null;

View File

@@ -1,34 +0,0 @@
import type { Song } from "./types";
const NOTES_SHARP = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
const NOTES_FLAT = ["C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab", "A", "Bb", "B"];
function transposeChord(chord: string, semitones: number): string {
const match = chord.match(/^([A-G][#b]?)(.*)/);
if (!match) return chord;
const [, root, descriptor] = match;
const idx = NOTES_SHARP.indexOf(root) !== -1
? NOTES_SHARP.indexOf(root)
: NOTES_FLAT.indexOf(root);
if (idx === -1) return chord;
const newIdx = ((idx + semitones) % 12 + 12) % 12;
const notes = semitones >= 0 ? NOTES_SHARP : NOTES_FLAT;
return notes[newIdx] + descriptor;
}
export function transposeSong(song: Song, semitones: number): Song {
if (semitones === 0) return song;
return {
...song,
sections: song.sections.map((section) => ({
...section,
lines: section.lines.map((line) => ({
...line,
chords: line.chords.map((cp) => ({
...cp,
chord: transposeChord(cp.chord, semitones),
})),
})),
})),
};
}

View File

@@ -6,6 +6,7 @@ import {
Scripts, Scripts,
ScrollRestoration, ScrollRestoration,
} from "react-router"; } from "react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider } from "next-themes"; import { ThemeProvider } from "next-themes";
import type { Route } from "./+types/root"; import type { Route } from "./+types/root";
@@ -13,6 +14,10 @@ import "./app.css";
import { AuthProvider } from "./lib/auth"; import { AuthProvider } from "./lib/auth";
import { TooltipProvider } from "./components/ui/tooltip"; import { TooltipProvider } from "./components/ui/tooltip";
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 30_000, retry: 1 } },
});
export const links: Route.LinksFunction = () => [ export const links: Route.LinksFunction = () => [
{ rel: "preconnect", href: "https://fonts.googleapis.com" }, { rel: "preconnect", href: "https://fonts.googleapis.com" },
{ {
@@ -43,15 +48,17 @@ export function Layout({ children }: { children: React.ReactNode }) {
<Links /> <Links />
</head> </head>
<body> <body>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem> <QueryClientProvider client={queryClient}>
<AuthProvider> <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<TooltipProvider> <AuthProvider>
{children} <TooltipProvider>
<ScrollRestoration /> {children}
<Scripts /> <ScrollRestoration />
</TooltipProvider> <Scripts />
</AuthProvider> </TooltipProvider>
</ThemeProvider> </AuthProvider>
</ThemeProvider>
</QueryClientProvider>
</body> </body>
</html> </html>
); );

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useSearchParams } from "react-router"; import { useSearchParams } from "react-router";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
import { Card, CardContent } from "~/components/ui/card"; import { Card, CardContent } from "~/components/ui/card";
@@ -8,8 +9,8 @@ import { SongCard } from "~/components/song-card";
import { AddSongSheet } from "~/components/add-song-sheet"; import { AddSongSheet } from "~/components/add-song-sheet";
import { listSongs } from "~/lib/api"; import { listSongs } from "~/lib/api";
import { useAuth } from "~/lib/auth"; import { useAuth } from "~/lib/auth";
import { useFavorites } from "~/hooks/use-favorites";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import type { SongSummary } from "~/lib/types";
export function meta() { export function meta() {
return [ return [
@@ -20,12 +21,10 @@ export function meta() {
export default function Home() { export default function Home() {
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
const { isFavorite } = useFavorites();
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [sheetOpen, setSheetOpen] = useState(false); const [sheetOpen, setSheetOpen] = useState(false);
const [songs, setSongs] = useState<SongSummary[]>([]);
const [localSongs, setLocalSongs] = useState<SongSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const q = searchParams.get("q") ?? ""; const q = searchParams.get("q") ?? "";
const sort = searchParams.get("sort") ?? "date"; const sort = searchParams.get("sort") ?? "date";
@@ -34,17 +33,10 @@ export default function Home() {
const [inputValue, setInputValue] = useState(q); const [inputValue, setInputValue] = useState(q);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { const { data: songs = [], isLoading, isError, refetch } = useQuery({
setLoading(true); queryKey: ["songs", q, sort, order],
setError(false); queryFn: () => listSongs(q, sort, order),
listSongs(q, sort, order) });
.then((data) => {
setSongs(data);
setLocalSongs([]);
})
.catch(() => setError(true))
.finally(() => setLoading(false));
}, [q, sort, order]);
const handleSearch = useCallback( const handleSearch = useCallback(
(value: string) => { (value: string) => {
@@ -68,7 +60,11 @@ export default function Home() {
[], [],
); );
const allSongs = [...songs, ...localSongs]; const allSongs = songs.toSorted((a, b) => {
const af = isFavorite(a.id) ? 0 : 1;
const bf = isFavorite(b.id) ? 0 : 1;
return af - bf;
});
return ( return (
<div className="flex flex-col h-full max-w-lg mx-auto"> <div className="flex flex-col h-full max-w-lg mx-auto">
@@ -129,45 +125,38 @@ export default function Home() {
))} ))}
</div> </div>
{error && ( {isError && (
<div className="flex flex-col items-center gap-3 pt-8 pb-4 px-6 text-center"> <div className="flex flex-col items-center gap-3 pt-8 pb-4 px-6 text-center">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Couldn't load your songs. Is the API running? Couldn't load your songs. Is the API running?
</p> </p>
<Button <Button variant="outline" size="sm" onClick={() => refetch()}>
variant="outline"
size="sm"
onClick={() => {
setError(false);
setLoading(true);
listSongs(q, sort, order)
.then(setSongs)
.catch(() => setError(true))
.finally(() => setLoading(false));
}}
>
Retry Retry
</Button> </Button>
</div> </div>
)} )}
<div className="flex-1 overflow-y-auto px-4 pb-4"> <div className="flex-1 overflow-y-auto px-4 pb-4">
{loading && !error && ( {isLoading && !isError && (
<div className="flex justify-center pt-12"> <div className="flex justify-center pt-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /> <Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div> </div>
)} )}
{!loading && !error && allSongs.length === 0 && ( {!isLoading && !isError && allSongs.length === 0 && (
<p className="text-sm text-muted-foreground text-center pt-8 pb-4"> <p className="text-sm text-muted-foreground text-center pt-8 pb-4">
{q {q
? "No songs match your search." ? "No songs match your search."
: "No songs yet. Tap Add to get started."} : "No songs yet. Tap Add to get started."}
</p> </p>
)} )}
{!loading && ( {!isLoading && (
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
{allSongs.map((song) => ( {allSongs.map((song) => (
<SongCard key={song.id} song={song} /> <SongCard
key={song.id}
song={song}
isFavorite={isFavorite(song.id)}
/>
))} ))}
{isAuthenticated && ( {isAuthenticated && (
<Card <Card
@@ -186,7 +175,9 @@ export default function Home() {
<AddSongSheet <AddSongSheet
open={sheetOpen} open={sheetOpen}
onOpenChange={setSheetOpen} onOpenChange={setSheetOpen}
onSongAdded={(summary) => setLocalSongs((prev) => [...prev, summary])} onSongAdded={() =>
queryClient.invalidateQueries({ queryKey: ["songs"] })
}
/> />
</div> </div>
); );

View File

@@ -1,15 +1,26 @@
import { Outlet } from "react-router"; import { Outlet } from "react-router";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import { BottomNav } from "~/components/bottom-nav"; import { BottomNav } from "~/components/bottom-nav";
import { FullscreenProvider, useFullscreen } from "~/hooks/use-fullscreen";
function LayoutInner() {
const { isFullscreen } = useFullscreen();
export default function Layout() {
return ( return (
<div className="flex flex-col h-dvh"> <div className="flex flex-col h-dvh">
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
<Outlet /> <Outlet />
</div> </div>
<BottomNav /> {!isFullscreen && <BottomNav />}
<Toaster position="top-center" richColors /> <Toaster position="top-center" richColors />
</div> </div>
); );
} }
export default function Layout() {
return (
<FullscreenProvider>
<LayoutInner />
</FullscreenProvider>
);
}

View File

@@ -1,6 +1,8 @@
import { useEffect, useState, useRef, useCallback } from "react"; import { useState, useRef, useCallback } from "react";
import { Link, useParams } from "react-router"; import { Link, useParams } from "react-router";
import { useQuery } from "@tanstack/react-query";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { Button } from "~/components/ui/button";
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 { ChordGrid } from "~/components/chord-diagram/chord-grid";
@@ -8,11 +10,15 @@ import { ChordDiagram } from "~/components/chord-diagram/chord-diagram";
import type { Instrument } 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 { SectionNav } from "~/components/section-nav";
import { AutoScrollControls } from "~/components/auto-scroll-controls";
import { extractUniqueChords } from "~/lib/song-utils"; import { extractUniqueChords } from "~/lib/song-utils";
import { getSong } from "~/lib/api"; import { getSong } from "~/lib/api";
import { useAuth } from "~/lib/auth"; import { useAuth } from "~/lib/auth";
import type { Song, SongSummary } from "~/lib/types"; 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"; type FontSize = "sm" | "base" | "lg";
@@ -36,6 +42,19 @@ function initInstrument(): Instrument {
return "piano"; 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() { export function meta() {
return [{ title: "PocketChords" }]; return [{ title: "PocketChords" }];
} }
@@ -43,26 +62,13 @@ export function meta() {
export default function SongDetail() { export default function SongDetail() {
const { id = "" } = useParams(); const { id = "" } = useParams();
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
const { isFavorite, toggle: toggleFavorite } = useFavorites();
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen();
const [baseSong, setBaseSong] = useState<Song | null>(null); useWakeLock();
const [displayedSong, setDisplayedSong] = useState<Song | null>(null);
const [loading, setLoading] = useState(true); const [offset, setOffset] = useState(() => initOffset(id));
const [applyCapo, setApplyCapo] = useState(false); 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<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);
@@ -70,25 +76,16 @@ export default function SongDetail() {
const [instrument, setInstrument] = useState<Instrument>(initInstrument); const [instrument, setInstrument] = useState<Instrument>(initInstrument);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => { const { data: baseSong, isLoading } = useQuery({
setLoading(true); queryKey: ["song", id],
getSong(id) queryFn: () => getSong(id),
.then((s) => { });
setBaseSong(s);
setDisplayedSong(s);
})
.finally(() => setLoading(false));
}, [id]);
useEffect(() => { const { data: displayedSong } = useQuery({
if (applyCapo && baseSong?.meta.capo) { queryKey: ["song", id, "view", offset, applyCapo],
getSong(id, true).then((s) => { queryFn: () => getSong(id, { applyCapo, transpose: offset }),
if (s) setDisplayedSong(s); enabled: !!baseSong,
}); });
} else {
setDisplayedSong(baseSong);
}
}, [applyCapo]); // eslint-disable-line
function handleOffsetChange(newOffset: number) { function handleOffsetChange(newOffset: number) {
setOffset(newOffset); setOffset(newOffset);
@@ -119,7 +116,12 @@ export default function SongDetail() {
const handleScroll = useCallback(() => setActiveChord(null), []); const handleScroll = useCallback(() => setActiveChord(null), []);
if (loading) { function handleSectionJump(index: number) {
const el = document.getElementById(`section-${index}`);
el?.scrollIntoView({ behavior: "smooth", block: "start" });
}
if (isLoading) {
return ( return (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /> <Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
@@ -127,7 +129,9 @@ export default function SongDetail() {
); );
} }
if (!baseSong || !displayedSong) { const song = displayedSong ?? baseSong;
if (!baseSong || !song) {
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">
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
@@ -143,15 +147,15 @@ export default function SongDetail() {
); );
} }
const displayed = transposeSong(displayedSong, offset); const uniqueChords = extractUniqueChords(song.sections);
const uniqueChords = extractUniqueChords(displayed.sections); const sectionItems = song.sections.map((s, i) => ({
label: s.label,
index: i,
}));
const handleChordClick = (chord: string) => setActiveChord(chord); const handleChordClick = (chord: string) => setActiveChord(chord);
function handleUpdated(summary: SongSummary) { function handleUpdated(summary: SongSummary) {
setBaseSong((prev) => (prev ? { ...prev, meta: summary.meta } : prev)); // meta-only update; react-query will refetch on next focus
setDisplayedSong((prev) =>
prev ? { ...prev, meta: summary.meta } : prev,
);
} }
return ( return (
@@ -167,6 +171,10 @@ export default function SongDetail() {
capo={baseSong.meta.capo ?? undefined} capo={baseSong.meta.capo ?? undefined}
applyCapo={applyCapo} applyCapo={applyCapo}
onToggleCapo={() => setApplyCapo((v) => !v)} 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-hidden flex flex-col lg:flex-row">
@@ -177,7 +185,7 @@ export default function SongDetail() {
> >
<div className="max-w-lg mx-auto lg:max-w-none"> <div className="max-w-lg mx-auto lg:max-w-none">
<ChordChart <ChordChart
sections={displayed.sections} sections={song.sections}
fontSize={fontSize} fontSize={fontSize}
onChordClick={handleChordClick} onChordClick={handleChordClick}
/> />
@@ -201,15 +209,22 @@ export default function SongDetail() {
</div> </div>
</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 && ( {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"> <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} /> <ChordDiagram chord={activeChord} instrument={instrument} />
<button <Button
className="ml-auto text-muted-foreground text-xs underline-offset-4 hover:underline" variant="ghost"
size="sm"
className="ml-auto text-xs text-muted-foreground"
onClick={() => setActiveChord(null)} onClick={() => setActiveChord(null)}
> >
close close
</button> </Button>
</div> </div>
)} )}

27
app/package-lock.json generated
View File

@@ -10,6 +10,7 @@
"@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/inter": "^5.2.8",
"@react-router/node": "7.14.0", "@react-router/node": "7.14.0",
"@react-router/serve": "7.14.0", "@react-router/serve": "7.14.0",
"@tanstack/react-query": "^5.101.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
@@ -4045,6 +4046,32 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8" "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": { "node_modules/@tonaljs/abc-notation": {
"version": "4.9.1", "version": "4.9.1",
"resolved": "https://registry.npmjs.org/@tonaljs/abc-notation/-/abc-notation-4.9.1.tgz", "resolved": "https://registry.npmjs.org/@tonaljs/abc-notation/-/abc-notation-4.9.1.tgz",

View File

@@ -14,6 +14,7 @@
"@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/inter": "^5.2.8",
"@react-router/node": "7.14.0", "@react-router/node": "7.14.0",
"@react-router/serve": "7.14.0", "@react-router/serve": "7.14.0",
"@tanstack/react-query": "^5.101.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",

View File

@@ -29,6 +29,7 @@ pub struct UpdateSongRequest {
#[derive(Deserialize, ToSchema, IntoParams)] #[derive(Deserialize, ToSchema, IntoParams)]
pub struct GetSongQuery { pub struct GetSongQuery {
pub apply_capo: Option<bool>, pub apply_capo: Option<bool>,
pub transpose: Option<i8>,
} }
#[derive(Deserialize, ToSchema)] #[derive(Deserialize, ToSchema)]

View File

@@ -155,6 +155,11 @@ pub async fn get_song(
song song
}; };
let song = match params.transpose {
Some(semitones) if semitones != 0 => ChordTransposer.transpose_song(&song, semitones),
_ => song,
};
Ok(Json(song)) Ok(Json(song))
} }