From f80ed4e88a219a1205863d44646a9535e0b32b32 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Wed, 8 Apr 2026 04:09:25 +0200 Subject: [PATCH] feat(app): PWA manifest, persistent transpose, font size, capo toggle, library sort --- app/app/components/chord-chart.tsx | 16 ++++--- app/app/components/transpose-bar.tsx | 40 +++++++++++++++- app/app/lib/api.ts | 17 ++++--- app/app/root.tsx | 7 +++ app/app/routes/home.tsx | 45 +++++++++++++++--- app/app/routes/songs.$id.tsx | 71 +++++++++++++++++++++++----- app/public/manifest.json | 12 +++++ 7 files changed, 175 insertions(+), 33 deletions(-) create mode 100644 app/public/manifest.json diff --git a/app/app/components/chord-chart.tsx b/app/app/components/chord-chart.tsx index a81a722..25fa76c 100644 --- a/app/app/components/chord-chart.tsx +++ b/app/app/components/chord-chart.tsx @@ -6,6 +6,7 @@ const MAX_WIDTH = 38; interface Props { sections: Section[]; + fontSize?: 'sm' | 'base' | 'lg'; } function buildChordRow(chords: { offset: number; chord: string }[]): string { @@ -54,16 +55,16 @@ function segmentLine(line: LyricLine, maxWidth: number): LyricLine[] { return segments; } -function LineBlock({ line }: { line: LyricLine }) { +function LineBlock({ line, sizeClass }: { line: LyricLine; sizeClass: string }) { return (
{line.chords.length > 0 && ( -
+        
           {buildChordRow(line.chords)}
         
)} {line.text && ( -
+        
           {line.text}
         
)} @@ -71,7 +72,7 @@ function LineBlock({ line }: { line: LyricLine }) { ); } -function SectionBlock({ section }: { section: Section }) { +function SectionBlock({ section, sizeClass }: { section: Section; sizeClass: string }) { return (
{section.label && ( @@ -79,18 +80,19 @@ function SectionBlock({ section }: { section: Section }) { )} {section.lines.flatMap((line, i) => segmentLine(line, MAX_WIDTH).map((seg, j) => ( - + )) )}
); } -export function ChordChart({ sections }: Props) { +export function ChordChart({ sections, fontSize }: Props) { + const sizeClass = { sm: 'text-sm', base: 'text-base', lg: 'text-lg' }[fontSize ?? 'sm']; return (
{sections.map((section, i) => ( - + ))}
); diff --git a/app/app/components/transpose-bar.tsx b/app/app/components/transpose-bar.tsx index 3b90aea..5d3024e 100644 --- a/app/app/components/transpose-bar.tsx +++ b/app/app/components/transpose-bar.tsx @@ -4,6 +4,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "~/components/ui/dropdown-menu"; import { ChevronUp, ChevronDown, Minus, Plus, MoreHorizontal, Pencil, Trash2 } from "lucide-react"; +import { cn } from "~/lib/utils"; import type { SongMeta } from "~/lib/types"; interface Props { @@ -12,9 +13,14 @@ interface Props { onOffsetChange: (offset: number) => void; onEdit?: () => void; onDelete?: () => void; + fontSize?: 'sm' | 'base' | 'lg'; + onFontSizeChange?: (size: 'sm' | 'base' | 'lg') => void; + capo?: number; + applyCapo?: boolean; + onToggleCapo?: () => void; } -export function TransposeBar({ meta, offset, onOffsetChange, onEdit, onDelete }: Props) { +export function TransposeBar({ meta, offset, onOffsetChange, onEdit, onDelete, fontSize, onFontSizeChange, capo, applyCapo, onToggleCapo }: Props) { const [expanded, setExpanded] = useState(true); const label = offset === 0 ? "±0" : offset > 0 ? `+${offset}` : `${offset}`; @@ -75,10 +81,40 @@ export function TransposeBar({ meta, offset, onOffsetChange, onEdit, onDelete }:
{meta.original_key && Key: {meta.original_key}} - {meta.capo != null && Capo: {meta.capo}} + {capo != null && onToggleCapo ? ( + + ) : meta.capo != null ? ( + Capo: {meta.capo} + ) : null} {meta.tuning && {meta.tuning}}
+ {onFontSizeChange && ( +
+ {(['sm', 'base', 'lg'] as const).map((s) => ( + + ))} +
+ )}
+
+ {([["date", "Date"], ["title", "Title"], ["artist", "Artist"]] as const).map(([val, label]) => ( + + ))} +
{error && (
diff --git a/app/app/routes/songs.$id.tsx b/app/app/routes/songs.$id.tsx index 26ea945..6ad0ec7 100644 --- a/app/app/routes/songs.$id.tsx +++ b/app/app/routes/songs.$id.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { data, Link } from "react-router"; import type { Route } from "./+types/songs.$id"; import { TransposeBar } from "~/components/transpose-bar"; @@ -31,14 +31,57 @@ export async function loader({ params }: Route.LoaderArgs) { } } +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'; +} + export default function SongDetail({ loaderData }: Route.ComponentProps) { const { song: initialSong, id } = loaderData; - const [song, setSong] = useState(initialSong ?? null); - const [offset, setOffset] = useState(0); + const [baseSong, setBaseSong] = useState(initialSong ?? null); + const [displayedSong, setDisplayedSong] = useState(initialSong ?? null); + 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); - if (!song) { + useEffect(() => { + if (applyCapo && baseSong?.meta.capo) { + getSong(id, true).then((s) => { if (s) setDisplayedSong(s); }); + } else { + setDisplayedSong(baseSong); + } + }, [applyCapo]); // eslint-disable-line + + 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 */ } + } + + if (!baseSong || !displayedSong) { return (

Song not found or unavailable.

@@ -49,34 +92,40 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) { ); } - const displayed = transposeSong(song, offset); + const displayed = transposeSong(displayedSong, offset); function handleUpdated(summary: SongSummary) { - setSong((prev) => prev ? { ...prev, meta: summary.meta } : prev); + setBaseSong((prev) => prev ? { ...prev, meta: summary.meta } : prev); + setDisplayedSong((prev) => prev ? { ...prev, meta: summary.meta } : prev); } return (
setEditOpen(true)} onDelete={() => setDeleteOpen(true)} + fontSize={fontSize} + onFontSizeChange={handleFontSizeChange} + capo={baseSong.meta.capo ?? undefined} + applyCapo={applyCapo} + onToggleCapo={() => setApplyCapo((v) => !v)} />
- +
diff --git a/app/public/manifest.json b/app/public/manifest.json new file mode 100644 index 0000000..6bf0822 --- /dev/null +++ b/app/public/manifest.json @@ -0,0 +1,12 @@ +{ + "name": "PocketChords", + "short_name": "Chords", + "description": "Personal chord chart viewer", + "start_url": "/", + "display": "standalone", + "background_color": "#09090b", + "theme_color": "#09090b", + "icons": [ + { "src": "/favicon.ico", "sizes": "48x48", "type": "image/x-icon" } + ] +}