feat(app): section nav, auto-scroll, favorites, fullscreen, wake lock, transposed key
This commit is contained in:
106
app/app/components/auto-scroll-controls.tsx
Normal file
106
app/app/components/auto-scroll-controls.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
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 tick = useCallback(
|
||||
(time: number) => {
|
||||
if (!scrollRef.current) return;
|
||||
if (lastTimeRef.current) {
|
||||
const dt = (time - lastTimeRef.current) / 1000;
|
||||
scrollRef.current.scrollTop += speed * dt;
|
||||
|
||||
const el = scrollRef.current;
|
||||
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;
|
||||
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">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -85,15 +85,17 @@ function LineBlock({
|
||||
|
||||
function SectionBlock({
|
||||
section,
|
||||
index,
|
||||
sizeClass,
|
||||
onChordClick,
|
||||
}: {
|
||||
section: Section;
|
||||
index: number;
|
||||
sizeClass: string;
|
||||
onChordClick?: (chord: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<div className="mb-6" id={`section-${index}`}>
|
||||
{section.label && (
|
||||
<p className="text-xs text-muted-foreground mb-1">[{section.label}]</p>
|
||||
)}
|
||||
@@ -111,7 +113,7 @@ export function ChordChart({ sections, fontSize, onChordClick }: Props) {
|
||||
return (
|
||||
<div className="px-4 py-3">
|
||||
{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>
|
||||
);
|
||||
|
||||
44
app/app/components/section-nav.tsx
Normal file
44
app/app/components/section-nav.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
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();
|
||||
if (lower.startsWith("verse")) return label.replace(/verse\s*/i, "V");
|
||||
if (lower.startsWith("chorus")) return "C";
|
||||
if (lower.startsWith("bridge")) return "Br";
|
||||
if (lower.startsWith("pre-chorus") || lower.startsWith("prechorus"))
|
||||
return "PC";
|
||||
if (lower.startsWith("intro")) return "In";
|
||||
if (lower.startsWith("outro")) return "Out";
|
||||
if (label.length > 6) return label.slice(0, 5);
|
||||
return label;
|
||||
}
|
||||
|
||||
export function SectionNav({ sections, onJump }: Props) {
|
||||
const labeled = sections.filter((s) => s.label);
|
||||
if (labeled.length === 0) return null;
|
||||
|
||||
return (
|
||||
<ScrollArea className="w-full">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,21 @@
|
||||
import { Link } from "react-router";
|
||||
import { Star } from "lucide-react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
import type { SongSummary } from "~/lib/types";
|
||||
|
||||
interface Props {
|
||||
song: SongSummary;
|
||||
isFavorite?: boolean;
|
||||
}
|
||||
|
||||
export function SongCard({ song }: Props) {
|
||||
export function SongCard({ song, isFavorite }: Props) {
|
||||
return (
|
||||
<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">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{song.preview_chords.map((chord) => (
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} 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 type { SongMeta } from "~/lib/types";
|
||||
|
||||
@@ -13,41 +27,100 @@ interface Props {
|
||||
onOffsetChange: (offset: number) => void;
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
fontSize?: 'sm' | 'base' | 'lg';
|
||||
onFontSizeChange?: (size: 'sm' | 'base' | 'lg') => void;
|
||||
fontSize?: "sm" | "base" | "lg";
|
||||
onFontSizeChange?: (size: "sm" | "base" | "lg") => void;
|
||||
capo?: number;
|
||||
applyCapo?: boolean;
|
||||
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 label = offset === 0 ? "±0" : offset > 0 ? `+${offset}` : `${offset}`;
|
||||
|
||||
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" />
|
||||
const keyDisplay =
|
||||
meta.original_key && offset !== 0
|
||||
? `Key: ${meta.original_key} → ${transposedKey(meta.original_key, offset)}`
|
||||
: meta.original_key
|
||||
? `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>
|
||||
</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;
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!expanded) {
|
||||
return (
|
||||
@@ -55,7 +128,12 @@ export function TransposeBar({ meta, offset, onOffsetChange, onEdit, onDelete, f
|
||||
<span className="text-sm font-semibold truncate">{meta.title}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{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" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -71,8 +149,40 @@ export function TransposeBar({ meta, offset, onOffsetChange, onEdit, onDelete, f
|
||||
<span className="text-sm text-muted-foreground">{meta.artist}</span>
|
||||
</div>
|
||||
<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}
|
||||
<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" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -80,16 +190,19 @@ export function TransposeBar({ meta, offset, onOffsetChange, onEdit, onDelete, f
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<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 ? (
|
||||
<button
|
||||
onClick={onToggleCapo}
|
||||
className={cn(
|
||||
"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>
|
||||
) : meta.capo != null ? (
|
||||
<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">
|
||||
{onFontSizeChange && (
|
||||
<div className="flex items-center gap-1">
|
||||
{(['sm', 'base', 'lg'] as const).map((s) => (
|
||||
{(["sm", "base", "lg"] as const).map((s) => (
|
||||
<button
|
||||
key={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",
|
||||
fontSize === s
|
||||
? "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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8"
|
||||
onClick={() => onOffsetChange(Math.max(-11, offset - 1))}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onOffsetChange(Math.max(-11, offset - 1))}
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
</Button>
|
||||
<span className="w-8 text-center text-sm font-mono font-semibold">{label}</span>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8"
|
||||
onClick={() => onOffsetChange(Math.min(11, offset + 1))}>
|
||||
<span className="w-8 text-center text-sm font-mono font-semibold">
|
||||
{label}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onOffsetChange(Math.min(11, offset + 1))}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
45
app/app/hooks/use-favorites.ts
Normal file
45
app/app/hooks/use-favorites.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
|
||||
const STORAGE_KEY = "favorites";
|
||||
|
||||
let listeners: Array<() => void> = [];
|
||||
|
||||
function getSnapshot(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function subscribe(cb: () => void) {
|
||||
listeners.push(cb);
|
||||
return () => {
|
||||
listeners = listeners.filter((l) => l !== cb);
|
||||
};
|
||||
}
|
||||
|
||||
function notify() {
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
export function useFavorites() {
|
||||
const favorites = useSyncExternalStore(subscribe, getSnapshot, () => []);
|
||||
|
||||
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 };
|
||||
}
|
||||
22
app/app/hooks/use-fullscreen.tsx
Normal file
22
app/app/hooks/use-fullscreen.tsx
Normal 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);
|
||||
}
|
||||
28
app/app/hooks/use-wake-lock.ts
Normal file
28
app/app/hooks/use-wake-lock.ts
Normal 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(() => {});
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { SongCard } from "~/components/song-card";
|
||||
import { AddSongSheet } from "~/components/add-song-sheet";
|
||||
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";
|
||||
|
||||
@@ -20,6 +21,7 @@ export function meta() {
|
||||
|
||||
export default function Home() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { isFavorite } = useFavorites();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [songs, setSongs] = useState<SongSummary[]>([]);
|
||||
@@ -68,7 +70,12 @@ export default function Home() {
|
||||
[],
|
||||
);
|
||||
|
||||
const allSongs = [...songs, ...localSongs];
|
||||
const merged = [...songs, ...localSongs];
|
||||
const allSongs = merged.toSorted((a, b) => {
|
||||
const af = isFavorite(a.id) ? 0 : 1;
|
||||
const bf = isFavorite(b.id) ? 0 : 1;
|
||||
return af - bf;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full max-w-lg mx-auto">
|
||||
@@ -167,7 +174,7 @@ export default function Home() {
|
||||
{!loading && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{allSongs.map((song) => (
|
||||
<SongCard key={song.id} song={song} />
|
||||
<SongCard key={song.id} song={song} isFavorite={isFavorite(song.id)} />
|
||||
))}
|
||||
{isAuthenticated && (
|
||||
<Card
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
import { Outlet } from "react-router";
|
||||
import { Toaster } from "sonner";
|
||||
import { BottomNav } from "~/components/bottom-nav";
|
||||
import { FullscreenProvider, useFullscreen } from "~/hooks/use-fullscreen";
|
||||
|
||||
function LayoutInner() {
|
||||
const { isFullscreen } = useFullscreen();
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<div className="flex flex-col h-dvh">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<Outlet />
|
||||
</div>
|
||||
<BottomNav />
|
||||
{!isFullscreen && <BottomNav />}
|
||||
<Toaster position="top-center" richColors />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<FullscreenProvider>
|
||||
<LayoutInner />
|
||||
</FullscreenProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
import { Link, useParams } from "react-router";
|
||||
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";
|
||||
@@ -8,10 +9,15 @@ 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 { transposeSong } from "~/lib/transpose";
|
||||
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 { Song, SongSummary } from "~/lib/types";
|
||||
|
||||
type FontSize = "sm" | "base" | "lg";
|
||||
@@ -43,6 +49,10 @@ export function meta() {
|
||||
export default function SongDetail() {
|
||||
const { id = "" } = useParams();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { isFavorite, toggle: toggleFavorite } = useFavorites();
|
||||
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen();
|
||||
|
||||
useWakeLock();
|
||||
|
||||
const [baseSong, setBaseSong] = useState<Song | null>(null);
|
||||
const [displayedSong, setDisplayedSong] = useState<Song | null>(null);
|
||||
@@ -119,6 +129,11 @@ export default function SongDetail() {
|
||||
|
||||
const handleScroll = useCallback(() => setActiveChord(null), []);
|
||||
|
||||
function handleSectionJump(index: number) {
|
||||
const el = document.getElementById(`section-${index}`);
|
||||
el?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
@@ -145,6 +160,10 @@ export default function SongDetail() {
|
||||
|
||||
const displayed = transposeSong(displayedSong, offset);
|
||||
const uniqueChords = extractUniqueChords(displayed.sections);
|
||||
const sectionItems = displayed.sections.map((s, i) => ({
|
||||
label: s.label,
|
||||
index: i,
|
||||
}));
|
||||
const handleChordClick = (chord: string) => setActiveChord(chord);
|
||||
|
||||
function handleUpdated(summary: SongSummary) {
|
||||
@@ -167,6 +186,10 @@ export default function SongDetail() {
|
||||
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">
|
||||
@@ -201,15 +224,23 @@ export default function SongDetail() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom toolbar: section nav + auto-scroll */}
|
||||
<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
|
||||
className="ml-auto text-muted-foreground text-xs underline-offset-4 hover:underline"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto text-xs text-muted-foreground"
|
||||
onClick={() => setActiveChord(null)}
|
||||
>
|
||||
close
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user